├── src ├── constants.ts ├── helpers │ ├── textFormatter.ts │ ├── createErrorResponse.ts │ ├── validateApiKey.ts │ └── __tests__ │ │ ├── textFormatter.test.ts │ │ ├── validateApiKey.test.ts │ │ └── createErrorResponse.test.ts ├── types.ts ├── tools │ ├── getExperiences.ts │ ├── getJobSummary.ts │ ├── getJobDetail.ts │ ├── getWantToDo.ts │ ├── deleteExperience.ts │ ├── updateJobSummary.ts │ ├── updateWantToDo.ts │ ├── __tests__ │ │ ├── deleteExperience.test.ts │ │ ├── getJobSummary.test.ts │ │ ├── getWantToDo.test.ts │ │ ├── getJobDetail.test.ts │ │ ├── updateJobSummary.test.ts │ │ ├── updateWantToDo.test.ts │ │ ├── getExperiences.test.ts │ │ ├── getTechSkill.test.ts │ │ ├── createExperience.test.ts │ │ ├── updateExperience.test.ts │ │ ├── updateTechSkill.test.ts │ │ └── searchJobs.test.ts │ ├── getTechSkill.ts │ ├── updateTechSkill.ts │ ├── createExperience.ts │ ├── updateExperience.ts │ └── searchJobs.ts └── index.ts ├── assets └── icon.png ├── vitest.config.ts ├── tsconfig.json ├── lefthook.yaml ├── .gitignore ├── Dockerfile ├── .github └── workflows │ ├── ci.yml │ └── build-dxt.yml ├── CHANGELOG.md ├── biome.json ├── LICENSE ├── CONTRIBUTING.md ├── package.json ├── manifest.json └── README.md /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const BASE_URL = "https://lapras.com/api/mcp"; 2 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lapras-inc/lapras-mcp-server/HEAD/assets/icon.png -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: "node", 7 | include: ["src/**/*.{test,spec}.{js,ts}"], 8 | }, 9 | resolve: { 10 | extensions: [".ts", ".js"], 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /src/helpers/textFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * エスケープされた文字列を実際の文字に変換します 3 | * @see https://github.com/lapras-inc/lapras-mcp-server/issues/5 4 | */ 5 | export function unescapeText(text: string | undefined | null): string { 6 | if (!text) return ""; 7 | 8 | // エスケープされた改行文字とタブ文字を実際の文字に変換 9 | return text 10 | .replace(/\\n/g, "\n") // 改行 11 | .replace(/\\t/g, "\t"); // タブ 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "outDir": "./dist", 12 | "rootDir": "./src" 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules"] 16 | } -------------------------------------------------------------------------------- /lefthook.yaml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | biome-lint: 5 | glob: "*.{js,ts,jsx,tsx}" 6 | run: npx biome lint {staged_files} 7 | skip_empty: true 8 | fail_text: "Biomeによるlintチェックが失敗しました。エラーを修正してください。" 9 | biome-format: 10 | glob: "*.{js,ts,jsx,tsx}" 11 | run: npx biome format {staged_files} --write 12 | skip_empty: true 13 | fail_text: "Biomeによるフォーマットが失敗しました。エラーを修正してください。" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Build output 5 | dist/ 6 | 7 | # Environment variables 8 | .env 9 | .env.local 10 | 11 | # Log files 12 | logs/ 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Editor directories and files 19 | .idea/ 20 | .vscode/ 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # Operating System Files 28 | .DS_Store 29 | Thumbs.db 30 | .cursor 31 | *.dxt -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.12-alpine AS builder 2 | 3 | COPY . /app 4 | 5 | WORKDIR /app 6 | 7 | RUN --mount=type=cache,target=/root/.npm npm install 8 | 9 | FROM node:22.12-alpine AS release 10 | 11 | COPY --from=builder /app/dist /app/dist 12 | COPY --from=builder /app/package.json /app/package.json 13 | COPY --from=builder /app/package-lock.json /app/package-lock.json 14 | 15 | ENV NODE_ENV=production 16 | 17 | WORKDIR /app 18 | 19 | RUN npm ci --ignore-scripts --omit-dev 20 | 21 | ENTRYPOINT ["node", "dist/index.js"] -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | ci: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Set up Node.js 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: '22' 16 | cache: 'npm' 17 | 18 | - name: Install dependencies 19 | run: npm ci 20 | 21 | - name: Run lint 22 | run: npm run lint 23 | 24 | - name: Run tests 25 | run: npm run test:ci -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.6.0] 2 | - Gemini CLIのセットアップ方法を追記 #11 3 | - LAPRASのテックスキル管理機能を追加 #12 4 | 5 | ## [0.4.0] 6 | 7 | ### Added 8 | - 職務要約・今後のキャリアでやりたいこと取得・更新Toolの追加 #7 9 | 10 | ## [0.3.1] 11 | 12 | ### Fixed 13 | - 改行文字をescapeする問題の対処 #6 14 | 15 | ## [0.3.0] 16 | 17 | ### Added 18 | - 職歴更新Toolの追加 19 | 20 | ## [0.2.0] 21 | 22 | ### Added 23 | - 職歴取得Toolの追加 24 | 25 | ### Fixed 26 | - コンテキスト長のエラーを回避するため画像URLを除外 27 | - コンテキスト長のエラーを回避するためtagsをjoin 28 | 29 | ## [0.1.1] 30 | 31 | ### Changed 32 | - typo修正 33 | - URLから求人応募の旨をdescriptionに追加 34 | 35 | ## [0.1.0] 36 | 37 | ### Added 38 | - 求人検索Toolの追加 39 | - 求人取得Toolの追加 40 | -------------------------------------------------------------------------------- /src/helpers/createErrorResponse.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | /** 4 | * エラーレスポンスを作成するユーティリティ関数 5 | * @param error エラー情報 6 | * @param errorMessage エラーメッセージ 7 | */ 8 | export function createErrorResponse( 9 | error: unknown, 10 | errorMessage: string, 11 | ): { 12 | content: TextContent[]; 13 | isError: boolean; 14 | } { 15 | const details = error instanceof Error ? error.message : String(error); 16 | 17 | return { 18 | content: [ 19 | { 20 | type: "text", 21 | text: JSON.stringify({ 22 | error: errorMessage, 23 | details, 24 | }), 25 | }, 26 | ], 27 | isError: true, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import type { z } from "zod"; 3 | 4 | /** 5 | * Zodスキーマから型を抽出するユーティリティ型 6 | */ 7 | export type InferZodParams> = { 8 | [K in keyof T]: z.infer; 9 | }; 10 | 11 | export interface IMCPTool = Record> { 12 | /** 13 | * ツール名 14 | */ 15 | readonly name: string; 16 | 17 | /** 18 | * ツールの説明 19 | */ 20 | readonly description: string; 21 | 22 | /** 23 | * パラメータの定義 24 | */ 25 | readonly parameters: TParams; 26 | 27 | /** 28 | * ツールを実行する 29 | * @param args パラメータ 30 | * @returns 実行結果 31 | */ 32 | execute(args: InferZodParams): Promise<{ 33 | content: TextContent[]; 34 | isError?: boolean; 35 | }>; 36 | } 37 | -------------------------------------------------------------------------------- /biome.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 | "lineWidth": 100 17 | }, 18 | "organizeImports": { 19 | "enabled": true 20 | }, 21 | "linter": { 22 | "enabled": true, 23 | "rules": { 24 | "recommended": true, 25 | "correctness": { 26 | "noUnusedVariables": "error" 27 | }, 28 | "suspicious": { 29 | "noExplicitAny": "off" 30 | }, 31 | "style": { 32 | "useImportType": "error" 33 | } 34 | } 35 | }, 36 | "javascript": { 37 | "formatter": { 38 | "quoteStyle": "double", 39 | "semicolons": "always" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/helpers/validateApiKey.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import { createErrorResponse } from "./createErrorResponse.js"; 3 | 4 | /** 5 | * APIキーの検証を行う関数 6 | * @returns 有効なAPIキーがある場合はAPIキーを、ない場合はエラーレスポンスを返す 7 | */ 8 | export function validateApiKey(): 9 | | { apiKey: string; isInvalid: false } 10 | | { 11 | errorResopnse: ReturnType; 12 | isInvalid: true; 13 | } { 14 | const lapras_api_key = process.env.LAPRAS_API_KEY?.trim(); 15 | if (!lapras_api_key) { 16 | return { 17 | errorResopnse: createErrorResponse( 18 | new Error("LAPRAS_API_KEY is required"), 19 | "LAPRAS_API_KEYの設定が必要です。https://lapras.com/config/api-key から取得してmcp.jsonに設定してください。", 20 | ), 21 | isInvalid: true, 22 | }; 23 | } 24 | return { apiKey: lapras_api_key, isInvalid: false }; 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 LAPRAS Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING GUIDE 2 | 3 | ## 開発ワークフロー 4 | 5 | ### インストール 6 | 7 | ```bash 8 | # リポジトリのクローン 9 | git clone https://github.com/lapras-inc/lapras-mcp-server.git 10 | cd lapras-mcp-server 11 | 12 | npm install 13 | ``` 14 | ### ビルド 15 | 16 | ```bash 17 | npm run build 18 | ``` 19 | 20 | ### Test 21 | 22 | ```bash 23 | npm test 24 | ``` 25 | 26 | ### Lint 27 | 28 | ```bash 29 | npm run lint 30 | npm run lint:fix 31 | ``` 32 | 33 | ### Pull Request 34 | 35 | PRを作成する際は、以下の情報を含めてください: 36 | 37 | 1. 変更内容の概要 38 | 2. 関連する Issue 番号(ある場合) 39 | 3. テスト方法 40 | 41 | ## Release 42 | 43 | ### npm 44 | 45 | ``` 46 | # LAPRASアカウントでログイン 47 | npm login 48 | ``` 49 | 50 | ``` 51 | # バージョン更新 52 | npm verson [major/minor/patch] 53 | ``` 54 | 55 | ``` 56 | # 公開 57 | npm publish 58 | ``` 59 | 60 | ### Docker Hub 61 | 62 | ``` 63 | # LAPRASアカウントでログイン 64 | docker login 65 | ``` 66 | 67 | ``` 68 | # build 69 | docker build -t lapras/mcp-server . 70 | ``` 71 | 72 | ``` 73 | # latestタグでpush 74 | docker tag lapras/mcp-server laprascom/lapras-mcp-server:latest 75 | docker push laprascom/lapras-mcp-server:latest 76 | ``` 77 | 78 | ### DXT 79 | 80 | 新しいtagをmainにpushするとCIでリリースと共に作成されるので特に対応不要。 81 | 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lapras-inc/lapras-mcp-server", 3 | "version": "0.6.0", 4 | "type": "module", 5 | "homepage": "https://github.com/lapras-inc/lapras-mcp-server", 6 | "bugs": "https://github.com/lapras-inc/lapras-mcp-server/issues", 7 | "bin": { 8 | "lapras-mcp-server": "dist/index.js" 9 | }, 10 | "main": "dist/index.js", 11 | "exports": { 12 | ".": "./dist/index.js" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "test": "vitest", 19 | "test:ci": "vitest run", 20 | "test:coverage": "vitest run --coverage", 21 | "build": "rm -rf dist && tsc && chmod +x dist/index.js", 22 | "prepare": "npm run build", 23 | "dev": "tsc --watch", 24 | "lint": "biome check ./src", 25 | "lint:fix": "biome check --write ./src" 26 | }, 27 | "keywords": [ 28 | "mcp", 29 | "lapras", 30 | "job_description" 31 | ], 32 | "author": "lapras-inc", 33 | "license": "MIT", 34 | "description": "MCP server for lapras.com", 35 | "dependencies": { 36 | "@modelcontextprotocol/sdk": "^1.8.0", 37 | "node-fetch": "^3.3.2", 38 | "zod": "^3.24.2" 39 | }, 40 | "devDependencies": { 41 | "@biomejs/biome": "1.9.4", 42 | "@types/node": "^22.14.0", 43 | "@vitest/ui": "^3.1.1", 44 | "lefthook": "^1.11.6", 45 | "typescript": "^5.8.2", 46 | "vitest": "^3.1.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/helpers/__tests__/textFormatter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { unescapeText } from "../textFormatter.js"; 3 | 4 | describe("unescapeText", () => { 5 | it("エスケープされた改行文字を実際の改行に変換する", () => { 6 | const input = "Line 1\\nLine 2\\nLine 3"; 7 | const expected = "Line 1\nLine 2\nLine 3"; 8 | expect(unescapeText(input)).toBe(expected); 9 | }); 10 | 11 | it("通常の文字列はそのまま返す", () => { 12 | const input = "Normal text without escapes"; 13 | expect(unescapeText(input)).toBe(input); 14 | }); 15 | 16 | it("undefinedの場合は空文字列を返す", () => { 17 | expect(unescapeText(undefined)).toBe(""); 18 | }); 19 | 20 | it("nullの場合は空文字列を返す", () => { 21 | expect(unescapeText(null)).toBe(""); 22 | }); 23 | 24 | it("複数の連続した改行文字を正しく変換する", () => { 25 | const input = "Line 1\\n\\nLine 3"; 26 | const expected = "Line 1\n\nLine 3"; 27 | expect(unescapeText(input)).toBe(expected); 28 | }); 29 | 30 | it("タブ文字を正しく変換する", () => { 31 | const input = "Column1\\tColumn2\\tColumn3"; 32 | const expected = "Column1\tColumn2\tColumn3"; 33 | expect(unescapeText(input)).toBe(expected); 34 | }); 35 | 36 | it("改行とタブを組み合わせて正しく変換する", () => { 37 | const input = "Title\\nColumn1\\tColumn2\\tColumn3\\nData1\\tData2"; 38 | const expected = "Title\nColumn1\tColumn2\tColumn3\nData1\tData2"; 39 | expect(unescapeText(input)).toBe(expected); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/helpers/__tests__/validateApiKey.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; 2 | import { validateApiKey } from "../validateApiKey.js"; 3 | 4 | describe("validateApiKey", () => { 5 | const originalEnv = process.env; 6 | 7 | beforeEach(() => { 8 | process.env = { ...originalEnv }; 9 | }); 10 | 11 | afterEach(() => { 12 | process.env = originalEnv; 13 | }); 14 | 15 | it("LAPRAS_API_KEYが設定されていない場合、エラーレスポンスを返す", () => { 16 | process.env.LAPRAS_API_KEY = undefined; 17 | const result = validateApiKey(); 18 | expect(result.isInvalid).toBe(true); 19 | if (result.isInvalid) { 20 | expect(result.errorResopnse.isError).toBe(true); 21 | expect(result.errorResopnse.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 22 | } 23 | }); 24 | 25 | it("LAPRAS_API_KEYが設定されている場合、APIキーを返す", () => { 26 | const testApiKey = "test-api-key"; 27 | process.env.LAPRAS_API_KEY = testApiKey; 28 | const result = validateApiKey(); 29 | expect(result.isInvalid).toBe(false); 30 | if (!result.isInvalid) { 31 | expect(result.apiKey).toBe(testApiKey); 32 | } 33 | }); 34 | 35 | it("LAPRAS_API_KEYが空白文字を含む場合、トリムされたAPIキーを返す", () => { 36 | const testApiKey = " test-api-key "; 37 | process.env.LAPRAS_API_KEY = testApiKey; 38 | const result = validateApiKey(); 39 | expect(result.isInvalid).toBe(false); 40 | if (!result.isInvalid) { 41 | expect(result.apiKey).toBe(testApiKey.trim()); 42 | } 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/helpers/__tests__/createErrorResponse.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { createErrorResponse } from "../createErrorResponse.js"; 3 | 4 | describe("createErrorResponse", () => { 5 | it("Errorインスタンスを渡した場合、エラーメッセージを含むレスポンスを返す", () => { 6 | const error = new Error("テストエラー"); 7 | const errorMessage = "エラーが発生しました"; 8 | const response = createErrorResponse(error, errorMessage); 9 | 10 | expect(response.isError).toBe(true); 11 | expect(response.content).toHaveLength(1); 12 | expect(response.content[0].type).toBe("text"); 13 | expect(JSON.parse(response.content[0].text)).toEqual({ 14 | error: errorMessage, 15 | details: error.message, 16 | }); 17 | }); 18 | 19 | it("Error以外のオブジェクトを渡した場合、文字列に変換したdetailsを含むレスポンスを返す", () => { 20 | const error = { code: 404, message: "Not Found" }; 21 | const errorMessage = "リソースが見つかりません"; 22 | const response = createErrorResponse(error, errorMessage); 23 | 24 | expect(response.isError).toBe(true); 25 | expect(response.content).toHaveLength(1); 26 | expect(response.content[0].type).toBe("text"); 27 | expect(JSON.parse(response.content[0].text)).toEqual({ 28 | error: errorMessage, 29 | details: String(error), 30 | }); 31 | }); 32 | 33 | it("プリミティブ値を渡した場合、文字列に変換したdetailsを含むレスポンスを返す", () => { 34 | const error = 404; 35 | const errorMessage = "不正なステータスコード"; 36 | const response = createErrorResponse(error, errorMessage); 37 | 38 | expect(response.isError).toBe(true); 39 | expect(response.content).toHaveLength(1); 40 | expect(response.content[0].type).toBe("text"); 41 | expect(JSON.parse(response.content[0].text)).toEqual({ 42 | error: errorMessage, 43 | details: String(error), 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/tools/getExperiences.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { BASE_URL } from "../constants.js"; 4 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 5 | import { validateApiKey } from "../helpers/validateApiKey.js"; 6 | import type { IMCPTool } from "../types.js"; 7 | 8 | /** 9 | * 職歴取得ツール 10 | */ 11 | export class GetExpriencesTool implements IMCPTool { 12 | /** 13 | * Tool name 14 | */ 15 | readonly name = "get_experiences"; 16 | 17 | /** 18 | * Tool description 19 | */ 20 | readonly description = "Get work experiences on LAPRAS(https://lapras.com)"; 21 | 22 | /** 23 | * Parameter definition 24 | */ 25 | readonly parameters = {} as const; 26 | 27 | /** 28 | * Execute function 29 | */ 30 | async execute(): Promise<{ 31 | content: TextContent[]; 32 | isError?: boolean; 33 | }> { 34 | const apiKeyResult = validateApiKey(); 35 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 36 | 37 | try { 38 | const url = new URL(`${BASE_URL}/experiences`); 39 | const response = await fetch(url, { 40 | headers: { 41 | accept: "application/json, text/plain, */*", 42 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 43 | }, 44 | method: "GET", 45 | }); 46 | 47 | if (!response.ok) { 48 | throw new Error(`API request failed with status: ${response.status}`); 49 | } 50 | 51 | const data = await response.json(); 52 | 53 | const content: TextContent[] = [ 54 | { 55 | type: "text", 56 | text: JSON.stringify(data, null, 2), 57 | }, 58 | ]; 59 | 60 | return { content }; 61 | } catch (error) { 62 | console.error(error); 63 | return createErrorResponse(error, "職歴の取得に失敗しました"); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/tools/getJobSummary.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { BASE_URL } from "../constants.js"; 4 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 5 | import { validateApiKey } from "../helpers/validateApiKey.js"; 6 | import type { IMCPTool } from "../types.js"; 7 | 8 | /** 9 | * 職務要約取得ツール 10 | */ 11 | export class GetJobSummaryTool implements IMCPTool { 12 | /** 13 | * Tool name 14 | */ 15 | readonly name = "get_job_summary"; 16 | 17 | /** 18 | * Tool description 19 | */ 20 | readonly description = "Get job summary(職務要約) on LAPRAS(https://lapras.com)"; 21 | 22 | /** 23 | * Parameter definition 24 | */ 25 | readonly parameters = {} as const; 26 | 27 | /** 28 | * Execute function 29 | */ 30 | async execute(): Promise<{ 31 | content: TextContent[]; 32 | isError?: boolean; 33 | }> { 34 | const apiKeyResult = validateApiKey(); 35 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 36 | 37 | try { 38 | const url = new URL(`${BASE_URL}/job_summary`); 39 | const response = await fetch(url, { 40 | headers: { 41 | accept: "application/json, text/plain, */*", 42 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 43 | }, 44 | method: "GET", 45 | }); 46 | 47 | if (!response.ok) { 48 | throw new Error(`API request failed with status: ${response.status}`); 49 | } 50 | 51 | const data = await response.json(); 52 | 53 | const content: TextContent[] = [ 54 | { 55 | type: "text", 56 | text: JSON.stringify(data, null, 2), 57 | }, 58 | ]; 59 | 60 | return { content }; 61 | } catch (error) { 62 | console.error(error); 63 | return createErrorResponse(error, "職務要約の取得に失敗しました"); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/tools/getJobDetail.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { z } from "zod"; 4 | import { BASE_URL } from "../constants.js"; 5 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 6 | import type { IMCPTool, InferZodParams } from "../types.js"; 7 | 8 | /** 9 | * 求人詳細取得ツール 10 | */ 11 | export class GetJobDetailTool implements IMCPTool { 12 | /** 13 | * Tool name 14 | */ 15 | readonly name = "get_job_detail"; 16 | 17 | /** 18 | * Tool description 19 | */ 20 | readonly description = 21 | "Get detailed information about a specific job posting. You can apply for jobs through the URL provided in the response."; 22 | 23 | /** 24 | * Parameter definition 25 | */ 26 | readonly parameters = { 27 | jobId: z.string().describe("The unique identifier of the job posting"), 28 | } as const; 29 | 30 | /** 31 | * Execute function 32 | */ 33 | async execute(args: InferZodParams): Promise<{ 34 | content: TextContent[]; 35 | isError?: boolean; 36 | }> { 37 | const { jobId } = args; 38 | if (!jobId) { 39 | return createErrorResponse(new Error("jobId is required"), "求人IDが必要です"); 40 | } 41 | 42 | const url = `${BASE_URL}/job_descriptions/${jobId}`; 43 | 44 | try { 45 | const response = await fetch(url); 46 | 47 | if (!response.ok) { 48 | throw new Error(`API request failed with status: ${response.status}`); 49 | } 50 | 51 | const data = await response.json(); 52 | 53 | const content: TextContent[] = [ 54 | { 55 | type: "text", 56 | text: JSON.stringify(data, null, 2), 57 | }, 58 | ]; 59 | 60 | return { content }; 61 | } catch (error) { 62 | console.error(error); 63 | return createErrorResponse(error, "求人詳細の取得に失敗しました"); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/tools/getWantToDo.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { BASE_URL } from "../constants.js"; 4 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 5 | import { validateApiKey } from "../helpers/validateApiKey.js"; 6 | import type { IMCPTool } from "../types.js"; 7 | 8 | /** 9 | * 今後のキャリアでやりたいこと取得ツール 10 | */ 11 | export class GetWantToDoTool implements IMCPTool { 12 | /** 13 | * Tool name 14 | */ 15 | readonly name = "get_want_to_do"; 16 | 17 | /** 18 | * Tool description 19 | */ 20 | readonly description = 21 | "Get career aspirations(今後のキャリアでやりたいこと) on LAPRAS(https://lapras.com)"; 22 | 23 | /** 24 | * Parameter definition 25 | */ 26 | readonly parameters = {} as const; 27 | 28 | /** 29 | * Execute function 30 | */ 31 | async execute(): Promise<{ 32 | content: TextContent[]; 33 | isError?: boolean; 34 | }> { 35 | const apiKeyResult = validateApiKey(); 36 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 37 | 38 | try { 39 | const url = new URL(`${BASE_URL}/want_to_do`); 40 | const response = await fetch(url, { 41 | headers: { 42 | accept: "application/json, text/plain, */*", 43 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 44 | }, 45 | method: "GET", 46 | }); 47 | 48 | if (!response.ok) { 49 | throw new Error(`API request failed with status: ${response.status}`); 50 | } 51 | 52 | const data = await response.json(); 53 | 54 | const content: TextContent[] = [ 55 | { 56 | type: "text", 57 | text: JSON.stringify(data, null, 2), 58 | }, 59 | ]; 60 | 61 | return { content }; 62 | } catch (error) { 63 | console.error(error); 64 | return createErrorResponse(error, "今後のキャリアでやりたいことの取得に失敗しました"); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/tools/deleteExperience.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { z } from "zod"; 4 | import { BASE_URL } from "../constants.js"; 5 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 6 | import { validateApiKey } from "../helpers/validateApiKey.js"; 7 | import type { IMCPTool, InferZodParams } from "../types.js"; 8 | 9 | /** 10 | * 職歴削除ツール 11 | */ 12 | export class DeleteExperienceTool implements IMCPTool { 13 | /** 14 | * Tool name 15 | */ 16 | readonly name = "delete_experience"; 17 | 18 | /** 19 | * Tool description 20 | */ 21 | readonly description = 22 | "Delete a work experience from LAPRAS(https://lapras.com). You can check the result at https://lapras.com/cv"; 23 | 24 | /** 25 | * Parameter definition 26 | */ 27 | readonly parameters = { 28 | experience_id: z.number().describe("ID of the experience to delete"), 29 | } as const; 30 | 31 | /** 32 | * Execute function 33 | */ 34 | async execute(args: InferZodParams): Promise<{ 35 | content: TextContent[]; 36 | isError?: boolean; 37 | }> { 38 | const apiKeyResult = validateApiKey(); 39 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 40 | 41 | try { 42 | const response = await fetch(new URL(`${BASE_URL}/experiences/${args.experience_id}`), { 43 | method: "DELETE", 44 | headers: { 45 | accept: "application/json, text/plain, */*", 46 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 47 | }, 48 | }); 49 | 50 | if (!response.ok) { 51 | throw new Error(`API request failed with status: ${response.status}`); 52 | } 53 | 54 | return { 55 | content: [{ type: "text", text: "職歴の削除が完了しました" }], 56 | }; 57 | } catch (error) { 58 | console.error(error); 59 | return createErrorResponse(error, "職歴の削除に失敗しました"); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { CreateExperienceTool } from "./tools/createExperience.js"; 6 | import { DeleteExperienceTool } from "./tools/deleteExperience.js"; 7 | import { GetExpriencesTool } from "./tools/getExperiences.js"; 8 | import { GetJobDetailTool } from "./tools/getJobDetail.js"; 9 | import { GetJobSummaryTool } from "./tools/getJobSummary.js"; 10 | import { GetTechSkillTool } from "./tools/getTechSkill.js"; 11 | import { GetWantToDoTool } from "./tools/getWantToDo.js"; 12 | import { SearchJobsTool } from "./tools/searchJobs.js"; 13 | import { UpdateExperienceTool } from "./tools/updateExperience.js"; 14 | import { UpdateJobSummaryTool } from "./tools/updateJobSummary.js"; 15 | import { UpdateTechSkillTool } from "./tools/updateTechSkill.js"; 16 | import { UpdateWantToDoTool } from "./tools/updateWantToDo.js"; 17 | import type { IMCPTool } from "./types.js"; 18 | 19 | export const ALL_TOOLS: IMCPTool[] = [ 20 | new SearchJobsTool(), // 求人検索ツール 21 | new GetJobDetailTool(), // 求人詳細取得ツール 22 | new GetExpriencesTool(), // 職歴取得ツール 23 | new CreateExperienceTool(), // 職歴新規追加ツール 24 | new UpdateExperienceTool(), // 職歴更新ツール 25 | new DeleteExperienceTool(), // 職歴削除ツール 26 | new GetJobSummaryTool(), // 職務要約取得ツール 27 | new UpdateJobSummaryTool(), // 職務要約更新ツール 28 | new GetWantToDoTool(), // 今後のキャリアでやりたいこと取得ツール 29 | new UpdateWantToDoTool(), // 今後のキャリアでやりたいこと更新ツール 30 | new GetTechSkillTool(), // テックスキル取得ツール 31 | new UpdateTechSkillTool(), // テックスキル更新ツール 32 | ]; 33 | 34 | const server = new McpServer( 35 | { 36 | name: "LAPRAS", 37 | version: "0.1.0", 38 | }, 39 | { 40 | capabilities: { 41 | tools: {}, 42 | }, 43 | }, 44 | ); 45 | 46 | for (const tool of ALL_TOOLS) { 47 | server.tool(tool.name, tool.description, tool.parameters, tool.execute.bind(tool)); 48 | } 49 | 50 | const transport = new StdioServerTransport(); 51 | await server.connect(transport); 52 | -------------------------------------------------------------------------------- /src/tools/updateJobSummary.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { z } from "zod"; 4 | import { BASE_URL } from "../constants.js"; 5 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 6 | import { unescapeText } from "../helpers/textFormatter.js"; 7 | import { validateApiKey } from "../helpers/validateApiKey.js"; 8 | import type { IMCPTool, InferZodParams } from "../types.js"; 9 | 10 | /** 11 | * 職務要約更新ツール 12 | */ 13 | export class UpdateJobSummaryTool implements IMCPTool { 14 | /** 15 | * Tool name 16 | */ 17 | readonly name = "update_job_summary"; 18 | 19 | /** 20 | * Tool description 21 | */ 22 | readonly description = 23 | "Update job summary(職務要約) on LAPRAS(https://lapras.com). You can check the result at https://lapras.com/cv"; 24 | 25 | /** 26 | * Parameter definition 27 | */ 28 | readonly parameters = { 29 | job_summary: z.string().max(10000).describe("Job summary(職務要約)"), 30 | } as const; 31 | 32 | /** 33 | * Execute function 34 | */ 35 | async execute(args: InferZodParams): Promise<{ 36 | content: TextContent[]; 37 | isError?: boolean; 38 | }> { 39 | const apiKeyResult = validateApiKey(); 40 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 41 | 42 | try { 43 | const response = await fetch(new URL(`${BASE_URL}/job_summary`), { 44 | method: "PUT", 45 | headers: { 46 | accept: "application/json, text/plain, */*", 47 | "Content-Type": "application/json", 48 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 49 | }, 50 | body: JSON.stringify({ 51 | job_summary: unescapeText(args.job_summary), 52 | }), 53 | }); 54 | 55 | if (!response.ok) { 56 | throw new Error(`API request failed with status: ${response.status}`); 57 | } 58 | 59 | const data = await response.json(); 60 | return { 61 | content: [{ type: "text", text: JSON.stringify(data, null, 2) }], 62 | }; 63 | } catch (error) { 64 | console.error(error); 65 | return createErrorResponse(error, "職務要約の更新に失敗しました"); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/tools/updateWantToDo.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { z } from "zod"; 4 | import { BASE_URL } from "../constants.js"; 5 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 6 | import { unescapeText } from "../helpers/textFormatter.js"; 7 | import { validateApiKey } from "../helpers/validateApiKey.js"; 8 | import type { IMCPTool, InferZodParams } from "../types.js"; 9 | 10 | /** 11 | * 今後のキャリアでやりたいこと更新ツール 12 | */ 13 | export class UpdateWantToDoTool implements IMCPTool { 14 | /** 15 | * Tool name 16 | */ 17 | readonly name = "update_want_to_do"; 18 | 19 | /** 20 | * Tool description 21 | */ 22 | readonly description = 23 | "Update career aspirations(今後のキャリアでやりたいこと) on LAPRAS(https://lapras.com). You can check the result at https://lapras.com/cv"; 24 | 25 | /** 26 | * Parameter definition 27 | */ 28 | readonly parameters = { 29 | want_to_do: z.string().max(1000).describe("Career aspirations(今後のキャリアでやりたいこと)"), 30 | } as const; 31 | 32 | /** 33 | * Execute function 34 | */ 35 | async execute(args: InferZodParams): Promise<{ 36 | content: TextContent[]; 37 | isError?: boolean; 38 | }> { 39 | const apiKeyResult = validateApiKey(); 40 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 41 | 42 | try { 43 | const response = await fetch(new URL(`${BASE_URL}/want_to_do`), { 44 | method: "PUT", 45 | headers: { 46 | accept: "application/json, text/plain, */*", 47 | "Content-Type": "application/json", 48 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 49 | }, 50 | body: JSON.stringify({ 51 | want_to_do: unescapeText(args.want_to_do), 52 | }), 53 | }); 54 | 55 | if (!response.ok) { 56 | throw new Error(`API request failed with status: ${response.status}`); 57 | } 58 | 59 | const data = await response.json(); 60 | return { 61 | content: [{ type: "text", text: JSON.stringify(data, null, 2) }], 62 | }; 63 | } catch (error) { 64 | console.error(error); 65 | return createErrorResponse(error, "今後のキャリアでやりたいことの更新に失敗しました"); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/tools/__tests__/deleteExperience.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { DeleteExperienceTool } from "../deleteExperience.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("DeleteExperienceTool", () => { 12 | let tool: DeleteExperienceTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new DeleteExperienceTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("職歴を正常に削除できる", async () => { 29 | mockFetch.mockResolvedValueOnce({ 30 | ok: true, 31 | }); 32 | 33 | const result = await tool.execute({ experience_id: 123 }); 34 | 35 | expect(result.isError).toBeUndefined(); 36 | expect(result.content[0].type).toBe("text"); 37 | expect(result.content[0].text).toBe("職歴の削除が完了しました"); 38 | 39 | expect(mockFetch).toHaveBeenCalledWith( 40 | expect.any(URL), 41 | expect.objectContaining({ 42 | method: "DELETE", 43 | headers: { 44 | accept: "application/json, text/plain, */*", 45 | Authorization: "Bearer test-api-key", 46 | }, 47 | }), 48 | ); 49 | }); 50 | 51 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 52 | process.env.LAPRAS_API_KEY = undefined; 53 | 54 | const result = await tool.execute({ experience_id: 123 }); 55 | 56 | expect(result.isError).toBe(true); 57 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 58 | }); 59 | 60 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 61 | const originalConsoleError = console.error; 62 | console.error = vi.fn(); 63 | 64 | mockFetch.mockResolvedValueOnce({ 65 | ok: false, 66 | status: 404, 67 | }); 68 | 69 | const result = await tool.execute({ experience_id: 123 }); 70 | 71 | expect(result.isError).toBe(true); 72 | expect(result.content[0].text).toContain("職歴の削除に失敗しました"); 73 | expect(console.error).toHaveBeenCalled(); 74 | 75 | console.error = originalConsoleError; 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /.github/workflows/build-dxt.yml: -------------------------------------------------------------------------------- 1 | name: Build Desktop Extension 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write # Required for creating releases and uploading assets 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: '22' # Using latest LTS version 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Build project 29 | run: npm run build 30 | 31 | - name: Install DXT CLI globally 32 | run: npm install -g @anthropic-ai/dxt 33 | 34 | - name: Create DXT package 35 | run: | 36 | # Get version from manifest.json 37 | VERSION=$(node -p "require('./manifest.json').version") 38 | echo "Building DXT for version: $VERSION" 39 | 40 | # Create DXT directly in project root (dxt pack includes dependencies) 41 | npx @anthropic-ai/dxt pack 42 | 43 | # Rename to include version 44 | mv lapras-mcp-server.dxt lapras-mcp-server-v${VERSION}.dxt 45 | 46 | # Also create a copy without version for consistency 47 | cp lapras-mcp-server-v${VERSION}.dxt lapras-mcp-server.dxt 48 | 49 | - name: Verify DXT creation 50 | run: | 51 | if [ ! -f lapras-mcp-server.dxt ]; then 52 | echo "Error: DXT file not created" 53 | exit 1 54 | fi 55 | ls -la *.dxt 56 | 57 | - name: Upload DXT as artifact 58 | uses: actions/upload-artifact@v4 59 | with: 60 | name: lapras-mcp-server-dxt 61 | path: | 62 | lapras-mcp-server.dxt 63 | lapras-mcp-server-v*.dxt 64 | 65 | release: 66 | needs: build 67 | runs-on: ubuntu-latest 68 | if: startsWith(github.ref, 'refs/tags/') 69 | 70 | steps: 71 | - name: Download DXT artifact 72 | uses: actions/download-artifact@v4 73 | with: 74 | name: lapras-mcp-server-dxt 75 | 76 | - name: List downloaded files 77 | run: ls -la 78 | 79 | - name: Create Release 80 | uses: softprops/action-gh-release@v2 81 | with: 82 | files: | 83 | lapras-mcp-server.dxt 84 | lapras-mcp-server-v*.dxt 85 | generate_release_notes: true 86 | fail_on_unmatched_files: true -------------------------------------------------------------------------------- /src/tools/__tests__/getJobSummary.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { GetJobSummaryTool } from "../getJobSummary.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("GetJobSummaryTool", () => { 12 | let tool: GetJobSummaryTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new GetJobSummaryTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("職務要約を正常に取得できる", async () => { 29 | const mockData = { 30 | job_summary: "これは職務要約のサンプルテキストです。", 31 | }; 32 | 33 | mockFetch.mockResolvedValueOnce({ 34 | ok: true, 35 | json: () => Promise.resolve(mockData), 36 | }); 37 | 38 | const result = await tool.execute(); 39 | 40 | expect(result.isError).toBeUndefined(); 41 | expect(result.content[0].type).toBe("text"); 42 | expect(result.content[0].text).toBe(JSON.stringify(mockData, null, 2)); 43 | 44 | expect(mockFetch).toHaveBeenCalledWith( 45 | expect.any(URL), 46 | expect.objectContaining({ 47 | method: "GET", 48 | headers: { 49 | accept: "application/json, text/plain, */*", 50 | Authorization: "Bearer test-api-key", 51 | }, 52 | }), 53 | ); 54 | }); 55 | 56 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 57 | process.env.LAPRAS_API_KEY = undefined; 58 | 59 | const result = await tool.execute(); 60 | 61 | expect(result.isError).toBe(true); 62 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 63 | }); 64 | 65 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 66 | const originalConsoleError = console.error; 67 | console.error = vi.fn(); 68 | 69 | mockFetch.mockResolvedValueOnce({ 70 | ok: false, 71 | status: 400, 72 | json: () => Promise.resolve({}), 73 | }); 74 | 75 | const result = await tool.execute(); 76 | expect(result.isError).toBe(true); 77 | expect(result.content[0].text).toContain("職務要約の取得に失敗しました"); 78 | 79 | console.error = originalConsoleError; 80 | }); 81 | 82 | it("ネットワークエラーが発生した場合は適切にエラーハンドリングする", async () => { 83 | const originalConsoleError = console.error; 84 | console.error = vi.fn(); 85 | 86 | const networkError = new Error("Network error"); 87 | mockFetch.mockRejectedValueOnce(networkError); 88 | 89 | const result = await tool.execute(); 90 | expect(result.isError).toBe(true); 91 | expect(result.content[0].text).toContain("職務要約の取得に失敗しました"); 92 | 93 | console.error = originalConsoleError; 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/tools/__tests__/getWantToDo.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { GetWantToDoTool } from "../getWantToDo.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("GetWantToDoTool", () => { 12 | let tool: GetWantToDoTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new GetWantToDoTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("今後のキャリアでやりたいことを正常に取得できる", async () => { 29 | const mockData = { 30 | want_to_do: "これからはAIエンジニアとして活躍したいです。", 31 | }; 32 | 33 | mockFetch.mockResolvedValueOnce({ 34 | ok: true, 35 | json: () => Promise.resolve(mockData), 36 | }); 37 | 38 | const result = await tool.execute(); 39 | 40 | expect(result.isError).toBeUndefined(); 41 | expect(result.content[0].type).toBe("text"); 42 | expect(result.content[0].text).toBe(JSON.stringify(mockData, null, 2)); 43 | 44 | expect(mockFetch).toHaveBeenCalledWith( 45 | expect.any(URL), 46 | expect.objectContaining({ 47 | method: "GET", 48 | headers: { 49 | accept: "application/json, text/plain, */*", 50 | Authorization: "Bearer test-api-key", 51 | }, 52 | }), 53 | ); 54 | }); 55 | 56 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 57 | process.env.LAPRAS_API_KEY = undefined; 58 | 59 | const result = await tool.execute(); 60 | 61 | expect(result.isError).toBe(true); 62 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 63 | }); 64 | 65 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 66 | const originalConsoleError = console.error; 67 | console.error = vi.fn(); 68 | 69 | mockFetch.mockResolvedValueOnce({ 70 | ok: false, 71 | status: 400, 72 | json: () => Promise.resolve({}), 73 | }); 74 | 75 | const result = await tool.execute(); 76 | expect(result.isError).toBe(true); 77 | expect(result.content[0].text).toContain("今後のキャリアでやりたいことの取得に失敗しました"); 78 | 79 | console.error = originalConsoleError; 80 | }); 81 | 82 | it("ネットワークエラーが発生した場合は適切にエラーハンドリングする", async () => { 83 | const originalConsoleError = console.error; 84 | console.error = vi.fn(); 85 | 86 | const networkError = new Error("Network error"); 87 | mockFetch.mockRejectedValueOnce(networkError); 88 | 89 | const result = await tool.execute(); 90 | expect(result.isError).toBe(true); 91 | expect(result.content[0].text).toContain("今後のキャリアでやりたいことの取得に失敗しました"); 92 | 93 | console.error = originalConsoleError; 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/tools/__tests__/getJobDetail.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { GetJobDetailTool } from "../getJobDetail.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("GetJobDetailTool", () => { 12 | let tool: GetJobDetailTool; 13 | let mockFetch: ReturnType; 14 | 15 | beforeEach(() => { 16 | tool = new GetJobDetailTool(); 17 | mockFetch = fetch as unknown as ReturnType; 18 | vi.clearAllMocks(); 19 | }); 20 | 21 | afterEach(() => { 22 | vi.resetAllMocks(); 23 | }); 24 | 25 | it("jobIdが空の場合はエラーを返す", async () => { 26 | const result = await tool.execute({ jobId: "" }); 27 | expect(result.isError).toBe(true); 28 | expect(result.content[0].text).toContain("求人IDが必要です"); 29 | }); 30 | 31 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 32 | const originalConsoleError = console.error; 33 | console.error = vi.fn(); 34 | 35 | mockFetch.mockResolvedValueOnce({ 36 | ok: false, 37 | status: 404, 38 | json: () => Promise.resolve({}), 39 | }); 40 | 41 | const result = await tool.execute({ jobId: "123" }); 42 | expect(result.isError).toBe(true); 43 | expect(result.content[0].text).toContain("求人詳細の取得に失敗しました"); 44 | expect(console.error).toHaveBeenCalled(); 45 | 46 | console.error = originalConsoleError; 47 | }); 48 | 49 | it("正常なレスポンスを適切に処理できる", async () => { 50 | const mockData = { 51 | job_description_id: "123", 52 | job_description: { 53 | title: "Software Engineer", 54 | company_name: "LAPRAS Inc.", 55 | description: "Job description here", 56 | }, 57 | company: { 58 | name: "LAPRAS Inc.", 59 | logo_image_url: "https://example.com/logo.png", 60 | }, 61 | images: ["image1.jpg", "image2.jpg"], 62 | }; 63 | 64 | mockFetch.mockResolvedValueOnce({ 65 | ok: true, 66 | json: () => Promise.resolve(mockData), 67 | }); 68 | 69 | const result = await tool.execute({ jobId: "123" }); 70 | 71 | expect(result.isError).toBeUndefined(); 72 | expect(result.content[0].type).toBe("text"); 73 | expect(result.content[0].text).toBe(JSON.stringify(mockData, null, 2)); 74 | 75 | expect(mockFetch).toHaveBeenCalledWith("https://lapras.com/api/mcp/job_descriptions/123"); 76 | }); 77 | 78 | it("ネットワークエラーが発生した場合は適切にエラーハンドリングする", async () => { 79 | const originalConsoleError = console.error; 80 | console.error = vi.fn(); 81 | 82 | const networkError = new Error("Network error"); 83 | mockFetch.mockRejectedValueOnce(networkError); 84 | 85 | const result = await tool.execute({ jobId: "123" }); 86 | expect(result.isError).toBe(true); 87 | expect(result.content[0].text).toContain("求人詳細の取得に失敗しました"); 88 | expect(console.error).toHaveBeenCalledWith(networkError); 89 | 90 | console.error = originalConsoleError; 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "dxt_version": "0.1", 3 | "name": "lapras-mcp-server", 4 | "display_name": "LAPRAS MCP Server", 5 | "version": "0.5.2", 6 | "description": "LAPRAS(https://lapras.com)公式 MCP Server。", 7 | "long_description": "LAPRAS(https://lapras.com)公式 MCP Server。LAPRAS上の求人検索やキャリア閲覧・編集をLLMサービスから直接利用できます。転職活動やキャリア管理を効率化できます。", 8 | "icon": "assets/icon.png", 9 | "author": { 10 | "name": "lapras-inc", 11 | "url": "https://github.com/lapras-inc/lapras-mcp-server" 12 | }, 13 | "server": { 14 | "type": "node", 15 | "entry_point": "dist/index.js", 16 | "mcp_config": { 17 | "command": "node", 18 | "args": ["${__dirname}/dist/index.js"], 19 | "env": { 20 | "LAPRAS_API_KEY": "${user_config.api_key}" 21 | } 22 | } 23 | }, 24 | "user_config": { 25 | "api_key": { 26 | "type": "string", 27 | "title": "LAPRAS API Key", 28 | "description": "API key for LAPRAS. Required for work experience related tools. Get it from https://lapras.com/config/api-key", 29 | "sensitive": true, 30 | "required": false 31 | } 32 | }, 33 | "tools": [ 34 | { 35 | "name": "search_job", 36 | "description": "Search jobs with keyword, page number, minimum salary, etc." 37 | }, 38 | { 39 | "name": "get_job_detail", 40 | "description": "Get detailed information about a specific job" 41 | }, 42 | { 43 | "name": "get_experiences", 44 | "description": "Get list of work experiences registered in LAPRAS" 45 | }, 46 | { 47 | "name": "create_experience", 48 | "description": "Add new work experience to LAPRAS" 49 | }, 50 | { 51 | "name": "update_experience", 52 | "description": "Update existing work experience in LAPRAS" 53 | }, 54 | { 55 | "name": "delete_experience", 56 | "description": "Delete work experience from LAPRAS" 57 | }, 58 | { 59 | "name": "get_job_summary", 60 | "description": "Get job summary registered in LAPRAS" 61 | }, 62 | { 63 | "name": "update_job_summary", 64 | "description": "Register or update job summary in LAPRAS" 65 | }, 66 | { 67 | "name": "get_want_to_do", 68 | "description": "Get future career aspirations registered in LAPRAS" 69 | }, 70 | { 71 | "name": "update_want_to_do", 72 | "description": "Register or update future career aspirations in LAPRAS" 73 | }, 74 | { 75 | "name": "get_tech_skill", 76 | "description": "Get tech skills registered in LAPRAS" 77 | }, 78 | { 79 | "name": "update_tech_skill", 80 | "description": "Register or update tech skills in LAPRAS" 81 | } 82 | ], 83 | "keywords": ["mcp", "lapras", "job_description", "career", "jobs"], 84 | "license": "MIT", 85 | "homepage": "https://github.com/lapras-inc/lapras-mcp-server", 86 | "documentation": "https://github.com/lapras-inc/lapras-mcp-server#readme", 87 | "support": "https://github.com/lapras-inc/lapras-mcp-server/issues", 88 | "repository": { 89 | "type": "git", 90 | "url": "https://github.com/lapras-inc/lapras-mcp-server" 91 | } 92 | } -------------------------------------------------------------------------------- /src/tools/__tests__/updateJobSummary.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { UpdateJobSummaryTool } from "../updateJobSummary.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("UpdateJobSummaryTool", () => { 12 | let tool: UpdateJobSummaryTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new UpdateJobSummaryTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("職務要約を正常に更新できる", async () => { 29 | const mockData = { 30 | error: false, 31 | }; 32 | 33 | mockFetch.mockResolvedValueOnce({ 34 | ok: true, 35 | json: () => Promise.resolve(mockData), 36 | }); 37 | 38 | const jobSummary = "新しい職務要約です。この職務要約は更新されます。"; 39 | const result = await tool.execute({ job_summary: jobSummary }); 40 | 41 | expect(result.isError).toBeUndefined(); 42 | expect(result.content[0].type).toBe("text"); 43 | expect(result.content[0].text).toBe(JSON.stringify(mockData, null, 2)); 44 | 45 | expect(mockFetch).toHaveBeenCalledWith( 46 | expect.any(URL), 47 | expect.objectContaining({ 48 | method: "PUT", 49 | headers: { 50 | accept: "application/json, text/plain, */*", 51 | "Content-Type": "application/json", 52 | Authorization: "Bearer test-api-key", 53 | }, 54 | body: JSON.stringify({ job_summary: jobSummary }), 55 | }), 56 | ); 57 | }); 58 | 59 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 60 | process.env.LAPRAS_API_KEY = undefined; 61 | 62 | const result = await tool.execute({ job_summary: "テスト" }); 63 | 64 | expect(result.isError).toBe(true); 65 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 66 | }); 67 | 68 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 69 | const originalConsoleError = console.error; 70 | console.error = vi.fn(); 71 | 72 | mockFetch.mockResolvedValueOnce({ 73 | ok: false, 74 | status: 400, 75 | json: () => Promise.resolve({}), 76 | }); 77 | 78 | const result = await tool.execute({ job_summary: "テスト" }); 79 | expect(result.isError).toBe(true); 80 | expect(result.content[0].text).toContain("職務要約の更新に失敗しました"); 81 | 82 | console.error = originalConsoleError; 83 | }); 84 | 85 | it("ネットワークエラーが発生した場合は適切にエラーハンドリングする", async () => { 86 | const originalConsoleError = console.error; 87 | console.error = vi.fn(); 88 | 89 | const networkError = new Error("Network error"); 90 | mockFetch.mockRejectedValueOnce(networkError); 91 | 92 | const result = await tool.execute({ job_summary: "テスト" }); 93 | expect(result.isError).toBe(true); 94 | expect(result.content[0].text).toContain("職務要約の更新に失敗しました"); 95 | 96 | console.error = originalConsoleError; 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/tools/__tests__/updateWantToDo.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { UpdateWantToDoTool } from "../updateWantToDo.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("UpdateWantToDoTool", () => { 12 | let tool: UpdateWantToDoTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new UpdateWantToDoTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("今後のキャリアでやりたいことを正常に更新できる", async () => { 29 | const mockData = { 30 | error: false, 31 | }; 32 | 33 | mockFetch.mockResolvedValueOnce({ 34 | ok: true, 35 | json: () => Promise.resolve(mockData), 36 | }); 37 | 38 | const wantToDo = "今後はAIエンジニアとして成長し、大規模プロジェクトをリードしていきたいです。"; 39 | const result = await tool.execute({ want_to_do: wantToDo }); 40 | 41 | expect(result.isError).toBeUndefined(); 42 | expect(result.content[0].type).toBe("text"); 43 | expect(result.content[0].text).toBe(JSON.stringify(mockData, null, 2)); 44 | 45 | expect(mockFetch).toHaveBeenCalledWith( 46 | expect.any(URL), 47 | expect.objectContaining({ 48 | method: "PUT", 49 | headers: { 50 | accept: "application/json, text/plain, */*", 51 | "Content-Type": "application/json", 52 | Authorization: "Bearer test-api-key", 53 | }, 54 | body: JSON.stringify({ want_to_do: wantToDo }), 55 | }), 56 | ); 57 | }); 58 | 59 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 60 | process.env.LAPRAS_API_KEY = undefined; 61 | 62 | const result = await tool.execute({ want_to_do: "テスト" }); 63 | 64 | expect(result.isError).toBe(true); 65 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 66 | }); 67 | 68 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 69 | const originalConsoleError = console.error; 70 | console.error = vi.fn(); 71 | 72 | mockFetch.mockResolvedValueOnce({ 73 | ok: false, 74 | status: 400, 75 | json: () => Promise.resolve({}), 76 | }); 77 | 78 | const result = await tool.execute({ want_to_do: "テスト" }); 79 | expect(result.isError).toBe(true); 80 | expect(result.content[0].text).toContain("今後のキャリアでやりたいことの更新に失敗しました"); 81 | 82 | console.error = originalConsoleError; 83 | }); 84 | 85 | it("ネットワークエラーが発生した場合は適切にエラーハンドリングする", async () => { 86 | const originalConsoleError = console.error; 87 | console.error = vi.fn(); 88 | 89 | const networkError = new Error("Network error"); 90 | mockFetch.mockRejectedValueOnce(networkError); 91 | 92 | const result = await tool.execute({ want_to_do: "テスト" }); 93 | expect(result.isError).toBe(true); 94 | expect(result.content[0].text).toContain("今後のキャリアでやりたいことの更新に失敗しました"); 95 | 96 | console.error = originalConsoleError; 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/tools/__tests__/getExperiences.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { GetExpriencesTool } from "../getExperiences.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("GetExpriencesTool", () => { 12 | let tool: GetExpriencesTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new GetExpriencesTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("職歴一覧を正常に取得できる", async () => { 29 | const mockData = { 30 | experiences: [ 31 | { 32 | id: 123, 33 | organization_name: "Test Company", 34 | positions: [{ id: 1 }], 35 | start_year: 2020, 36 | start_month: 1, 37 | end_year: 2023, 38 | end_month: 12, 39 | }, 40 | { 41 | id: 124, 42 | organization_name: "Another Company", 43 | positions: [{ id: 2 }], 44 | start_year: 2018, 45 | start_month: 4, 46 | end_year: 2020, 47 | end_month: 3, 48 | }, 49 | ], 50 | }; 51 | 52 | mockFetch.mockResolvedValueOnce({ 53 | ok: true, 54 | json: () => Promise.resolve(mockData), 55 | }); 56 | 57 | const result = await tool.execute(); 58 | 59 | expect(result.isError).toBeUndefined(); 60 | expect(result.content[0].type).toBe("text"); 61 | expect(result.content[0].text).toBe(JSON.stringify(mockData, null, 2)); 62 | 63 | expect(mockFetch).toHaveBeenCalledWith( 64 | expect.any(URL), 65 | expect.objectContaining({ 66 | method: "GET", 67 | headers: { 68 | accept: "application/json, text/plain, */*", 69 | Authorization: "Bearer test-api-key", 70 | }, 71 | }), 72 | ); 73 | }); 74 | 75 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 76 | process.env.LAPRAS_API_KEY = undefined; 77 | 78 | const result = await tool.execute(); 79 | 80 | expect(result.isError).toBe(true); 81 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 82 | }); 83 | 84 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 85 | const originalConsoleError = console.error; 86 | console.error = vi.fn(); 87 | 88 | mockFetch.mockResolvedValueOnce({ 89 | ok: false, 90 | status: 400, 91 | json: () => Promise.resolve({}), 92 | }); 93 | 94 | const result = await tool.execute(); 95 | expect(result.isError).toBe(true); 96 | expect(result.content[0].text).toContain("職歴の取得に失敗しました"); 97 | 98 | console.error = originalConsoleError; 99 | }); 100 | 101 | it("ネットワークエラーが発生した場合は適切にエラーハンドリングする", async () => { 102 | const originalConsoleError = console.error; 103 | console.error = vi.fn(); 104 | 105 | const networkError = new Error("Network error"); 106 | mockFetch.mockRejectedValueOnce(networkError); 107 | 108 | const result = await tool.execute(); 109 | expect(result.isError).toBe(true); 110 | expect(result.content[0].text).toContain("職歴の取得に失敗しました"); 111 | 112 | console.error = originalConsoleError; 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/tools/getTechSkill.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { BASE_URL } from "../constants.js"; 4 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 5 | import { validateApiKey } from "../helpers/validateApiKey.js"; 6 | import type { IMCPTool } from "../types.js"; 7 | 8 | const EXPERIENCE_YEARS_LABEL_MAP: Record = { 9 | 0: "1年未満", 10 | 1: "1年以上2年未満", 11 | 2: "2年以上3年未満", 12 | 3: "3年以上5年未満", 13 | 5: "5年以上10年未満", 14 | 10: "10年以上", 15 | }; 16 | 17 | const formatSkillYears = (yearsId: number): string => { 18 | return EXPERIENCE_YEARS_LABEL_MAP[yearsId] ?? "不明"; 19 | }; 20 | 21 | /** 22 | * テックスキル取得ツール 23 | */ 24 | export class GetTechSkillTool implements IMCPTool { 25 | /** 26 | * Tool name 27 | */ 28 | readonly name = "get_tech_skill"; 29 | 30 | /** 31 | * Tool description 32 | */ 33 | readonly description = 34 | "Get current tech skills(経験技術・スキル・資格) on LAPRAS(https://lapras.com)"; 35 | 36 | /** 37 | * Parameter definition 38 | */ 39 | readonly parameters = {} as const; 40 | 41 | /** 42 | * Execute function 43 | */ 44 | async execute(): Promise<{ 45 | content: TextContent[]; 46 | isError?: boolean; 47 | }> { 48 | const apiKeyResult = validateApiKey(); 49 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 50 | 51 | try { 52 | const masterResponse = await fetch(new URL(`${BASE_URL}/tech_skill/master`), { 53 | method: "GET", 54 | headers: { 55 | accept: "application/json, text/plain, */*", 56 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 57 | }, 58 | }); 59 | 60 | if (!masterResponse.ok) { 61 | throw new Error(`Failed to fetch tech skill master: ${masterResponse.status}`); 62 | } 63 | 64 | const masterData = (await masterResponse.json()) as { 65 | tech_skill_list: Array<{ id: number; name: string }>; 66 | }; 67 | 68 | const masterMap = new Map(masterData.tech_skill_list.map((skill) => [skill.id, skill.name])); 69 | 70 | const url = new URL(`${BASE_URL}/tech_skill`); 71 | const response = await fetch(url, { 72 | headers: { 73 | accept: "application/json, text/plain, */*", 74 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 75 | }, 76 | method: "GET", 77 | }); 78 | 79 | if (!response.ok) { 80 | throw new Error(`API request failed with status: ${response.status}`); 81 | } 82 | 83 | const data = (await response.json()) as { 84 | error: boolean; 85 | tech_skill_list: Array<{ tech_skill_id: number; years: number }>; 86 | updated_at: string; 87 | }; 88 | 89 | const formatted = data.tech_skill_list.map((skill) => { 90 | const name = masterMap.get(skill.tech_skill_id); 91 | return { 92 | tech_skill_id: skill.tech_skill_id, 93 | tech_skill_name: name ?? null, 94 | years_id: skill.years, 95 | years_label: formatSkillYears(skill.years), 96 | }; 97 | }); 98 | 99 | return { 100 | content: [ 101 | { 102 | type: "text", 103 | text: JSON.stringify( 104 | { 105 | error: data.error, 106 | updated_at: data.updated_at, 107 | tech_skill_list: formatted, 108 | }, 109 | null, 110 | 2, 111 | ), 112 | }, 113 | ], 114 | }; 115 | } catch (error) { 116 | console.error(error); 117 | return createErrorResponse(error, "テックスキルの取得に失敗しました"); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/tools/__tests__/getTechSkill.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { GetTechSkillTool } from "../getTechSkill.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("GetTechSkillTool", () => { 12 | let tool: GetTechSkillTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new GetTechSkillTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("テックスキル一覧を正常に取得できる", async () => { 29 | mockFetch 30 | .mockResolvedValueOnce({ 31 | ok: true, 32 | json: () => 33 | Promise.resolve({ 34 | tech_skill_list: [ 35 | { id: 1, name: "Python" }, 36 | { id: 2, name: "Go" }, 37 | ], 38 | }), 39 | }) 40 | .mockResolvedValueOnce({ 41 | ok: true, 42 | json: () => 43 | Promise.resolve({ 44 | error: false, 45 | tech_skill_list: [ 46 | { tech_skill_id: 1, years: 3 }, 47 | { tech_skill_id: 2, years: 5 }, 48 | ], 49 | updated_at: "2025-09-26T10:00:00Z", 50 | }), 51 | }); 52 | 53 | const result = await tool.execute(); 54 | 55 | expect(result.isError).toBeUndefined(); 56 | expect(result.content[0].type).toBe("text"); 57 | expect(result.content[0].text).toBe( 58 | JSON.stringify( 59 | { 60 | error: false, 61 | updated_at: "2025-09-26T10:00:00Z", 62 | tech_skill_list: [ 63 | { 64 | tech_skill_id: 1, 65 | tech_skill_name: "Python", 66 | years_id: 3, 67 | years_label: "3年以上5年未満", 68 | }, 69 | { 70 | tech_skill_id: 2, 71 | tech_skill_name: "Go", 72 | years_id: 5, 73 | years_label: "5年以上10年未満", 74 | }, 75 | ], 76 | }, 77 | null, 78 | 2, 79 | ), 80 | ); 81 | 82 | expect(mockFetch).toHaveBeenNthCalledWith( 83 | 1, 84 | expect.any(URL), 85 | expect.objectContaining({ 86 | method: "GET", 87 | headers: { 88 | accept: "application/json, text/plain, */*", 89 | Authorization: "Bearer test-api-key", 90 | }, 91 | }), 92 | ); 93 | 94 | expect(mockFetch).toHaveBeenNthCalledWith( 95 | 2, 96 | expect.any(URL), 97 | expect.objectContaining({ 98 | method: "GET", 99 | headers: { 100 | accept: "application/json, text/plain, */*", 101 | Authorization: "Bearer test-api-key", 102 | }, 103 | }), 104 | ); 105 | }); 106 | 107 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 108 | process.env.LAPRAS_API_KEY = undefined; 109 | 110 | const result = await tool.execute(); 111 | 112 | expect(result.isError).toBe(true); 113 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 114 | }); 115 | 116 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 117 | const originalConsoleError = console.error; 118 | console.error = vi.fn(); 119 | 120 | mockFetch.mockResolvedValueOnce({ 121 | ok: false, 122 | status: 400, 123 | json: () => Promise.resolve({}), 124 | }); 125 | 126 | const result = await tool.execute(); 127 | expect(result.isError).toBe(true); 128 | expect(result.content[0].text).toContain("テックスキルの取得に失敗しました"); 129 | 130 | console.error = originalConsoleError; 131 | }); 132 | 133 | it("ネットワークエラーが発生した場合は適切にエラーハンドリングする", async () => { 134 | const originalConsoleError = console.error; 135 | console.error = vi.fn(); 136 | 137 | const networkError = new Error("Network error"); 138 | mockFetch.mockRejectedValueOnce(networkError); 139 | 140 | const result = await tool.execute(); 141 | expect(result.isError).toBe(true); 142 | expect(result.content[0].text).toContain("テックスキルの取得に失敗しました"); 143 | 144 | console.error = originalConsoleError; 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/tools/updateTechSkill.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { z } from "zod"; 4 | import { BASE_URL } from "../constants.js"; 5 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 6 | import { validateApiKey } from "../helpers/validateApiKey.js"; 7 | import type { IMCPTool, InferZodParams } from "../types.js"; 8 | 9 | const EXPERIENCE_YEARS_ID_MAP: Array<{ max: number; id: number }> = [ 10 | { max: 1, id: 0 }, 11 | { max: 2, id: 1 }, 12 | { max: 3, id: 2 }, 13 | { max: 5, id: 3 }, 14 | { max: 10, id: 5 }, 15 | { max: Number.POSITIVE_INFINITY, id: 10 }, 16 | ]; 17 | 18 | const mapYearsToId = (years: number): number => { 19 | for (const { max, id } of EXPERIENCE_YEARS_ID_MAP) { 20 | if (years < max) return id; 21 | } 22 | throw new Error("Invalid years value"); 23 | }; 24 | 25 | /** 26 | * テックスキル更新ツール 27 | */ 28 | export class UpdateTechSkillTool implements IMCPTool { 29 | /** 30 | * Tool name 31 | */ 32 | readonly name = "update_tech_skill"; 33 | 34 | /** 35 | * Tool description 36 | */ 37 | readonly description = 38 | "Update tech skills(経験技術・スキル・資格) on LAPRAS(https://lapras.com)"; 39 | 40 | /** 41 | * Parameter definition 42 | */ 43 | readonly parameters = { 44 | tech_skill_list: z 45 | .array( 46 | z.object({ 47 | name: z.string().min(1).describe("Tech skill name"), 48 | years: z.number().min(0).describe("Years of experience (numeric value)"), 49 | }), 50 | ) 51 | .min(1) 52 | .describe("List of tech skills with experience years"), 53 | } as const; 54 | 55 | /** 56 | * Execute function 57 | */ 58 | async execute(args: InferZodParams): Promise<{ 59 | content: TextContent[]; 60 | isError?: boolean; 61 | }> { 62 | const apiKeyResult = validateApiKey(); 63 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 64 | 65 | try { 66 | const masterResponse = await fetch(new URL(`${BASE_URL}/tech_skill/master`), { 67 | method: "GET", 68 | headers: { 69 | accept: "application/json, text/plain, */*", 70 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 71 | }, 72 | }); 73 | 74 | if (!masterResponse.ok) { 75 | throw new Error(`Failed to fetch tech skill master: ${masterResponse.status}`); 76 | } 77 | 78 | const masterData = (await masterResponse.json()) as { 79 | tech_skill_list: Array<{ id: number; name: string }>; 80 | }; 81 | 82 | const nameToIdMap = new Map( 83 | masterData.tech_skill_list.map((skill) => [ 84 | skill.name.replace(/\s+/g, "").toLowerCase(), 85 | skill.id, 86 | ]), 87 | ); 88 | 89 | const requestBody = { 90 | tech_skill_list: args.tech_skill_list 91 | .map((skill) => { 92 | const normalizedName = skill.name.replace(/\s+/g, "").toLowerCase(); 93 | const techSkillId = nameToIdMap.get(normalizedName); 94 | if (!techSkillId) { 95 | return null; 96 | } 97 | 98 | return { 99 | tech_skill_id: techSkillId, 100 | years: mapYearsToId(skill.years), 101 | }; 102 | }) 103 | .filter((skill): skill is { tech_skill_id: number; years: number } => skill !== null), 104 | }; 105 | 106 | if (requestBody.tech_skill_list.length === 0) { 107 | return createErrorResponse( 108 | new Error("No valid tech skills to update"), 109 | "有効なテックスキルが存在しません。スキル名を確認してください。", 110 | ); 111 | } 112 | 113 | const response = await fetch(new URL(`${BASE_URL}/tech_skill`), { 114 | method: "PUT", 115 | headers: { 116 | accept: "application/json, text/plain, */*", 117 | "Content-Type": "application/json", 118 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 119 | }, 120 | body: JSON.stringify(requestBody), 121 | }); 122 | 123 | if (!response.ok) { 124 | throw new Error(`API request failed with status: ${response.status}`); 125 | } 126 | 127 | const data = await response.json(); 128 | return { 129 | content: [ 130 | { 131 | type: "text", 132 | text: JSON.stringify(data, null, 2), 133 | }, 134 | ], 135 | }; 136 | } catch (error) { 137 | console.error(error); 138 | return createErrorResponse(error, "テックスキルの更新に失敗しました"); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/tools/createExperience.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { z } from "zod"; 4 | import { BASE_URL } from "../constants.js"; 5 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 6 | import { unescapeText } from "../helpers/textFormatter.js"; 7 | import { validateApiKey } from "../helpers/validateApiKey.js"; 8 | import type { IMCPTool, InferZodParams } from "../types.js"; 9 | 10 | /** 11 | * 職歴新規追加ツール 12 | */ 13 | export class CreateExperienceTool implements IMCPTool { 14 | /** 15 | * Tool name 16 | */ 17 | readonly name = "create_experience"; 18 | 19 | /** 20 | * Tool description 21 | */ 22 | readonly description = 23 | "Create a new work experience on LAPRAS(https://lapras.com). You can check the result at https://lapras.com/cv"; 24 | 25 | /** 26 | * Parameter definition 27 | */ 28 | readonly parameters = { 29 | organization_name: z.string().describe("Name of the organization"), 30 | positions: z 31 | .array( 32 | z.object({ 33 | id: z 34 | .number() 35 | .describe( 36 | "Position type ID (1: フロントエンドエンジニア, 2: バックエンドエンジニア, 3: Webアプリケーションエンジニア, 4: インフラエンジニア, 5: SRE, 6: Android アプリエンジニア, 7: iOS アプリエンジニア, 8: モバイルエンジニア, 9: 機械学習エンジニア, 10: データサイエンティスト, 11: プロジェクトマネージャー, 12: プロダクトマネージャー, 13: テックリード, 14: エンジニアリングマネージャー, 15: リサーチエンジニア, 16: QA・テストエンジニア, 17: アーキテクト, 18: システムエンジニア, 19: 組み込みエンジニア, 20: データベースエンジニア, 21: ネットワークエンジニア, 22: セキュリティエンジニア, 23: スクラムマスター, 24: ゲームエンジニア, 25: CTO, 26: コーポレートエンジニア, 27: デザイナーその他, 28: データエンジニア, 29: CRE・テクニカルサポート, 30: セールスエンジニア・プリセールス, 32: ITエンジニアその他, 33: UI/UXデザイナー, 34: Webデザイナー, 35: ゲームデザイナー, 36: 動画制作・編集, 37: Webプロデューサー・ディレクター, 38: Webコンテンツ企画・編集・ライティング, 39: ゲームプロデューサー・ディレクター, 40: プロダクトマーケティングマネージャー, 41: 動画プロデューサー・ディレクター, 42: アートディレクター, 43: PM/ディレクターその他, 44: 営業, 45: 法人営業, 46: 個人営業, 47: 営業企画, 48: 営業事務, 49: 代理店営業, 50: インサイドセールス, 51: セールスその他, 52: 事業企画, 53: 経営企画, 54: 新規事業開発, 55: 事業開発その他, 56: カスタマーサクセス, 57: カスタマーサポート, 58: ヘルプデスク, 59: コールセンター管理・運営, 60: カスタマーサクセス・サポートその他, 61: 広報・PR・広告宣伝, 62: リサーチ・データ分析, 63: 商品企画・開発, 64: 販促, 65: MD・VMD・バイヤー, 66: Web広告運用・SEO・SNS運用, 67: CRM, 68: 広報・マーケティングその他, 69: 経営者・CEO・COO等, 70: CFO, 71: CIO, 72: 監査役, 73: 経営・CXOその他, 74: 経理, 75: 財務, 76: 法務, 77: 総務, 78: 労務, 79: 秘書, 80: 事務, 81: コーポレートその他, 82: 採用, 83: 人材開発・人材育成・研修, 84: 制度企画・組織開発, 85: 労務・給与, 86: 人事その他, 87: システムコンサルタント, 88: パッケージ導入コンサルタント, 89: セキュリティコンサルタント, 90: ネットワークコンサルタント, 91: ITコンサルタントその他, 92: 戦略コンサルタント, 93: DXコンサルタント, 94: 財務・会計コンサルタント, 95: 組織・人事コンサルタント, 96: 業務プロセスコンサルタント, 97: 物流コンサルタント, 98: リサーチャー・調査員, 99: コンサルタントその他, 100: その他)", 37 | ), 38 | }), 39 | ) 40 | .describe( 41 | "List of position type IDs - multiple selections are allowed. Please set relevant position types.", 42 | ), 43 | position_name: z.string().default("").describe("Position title"), 44 | is_client_work: z 45 | .boolean() 46 | .describe( 47 | "Whether this is client work (Set to true when the affiliated company and the project client are different, such as in contract development companies)", 48 | ), 49 | client_company_name: z 50 | .string() 51 | .optional() 52 | .describe("Client company name (required only when is_client_work is true)"), 53 | start_year: z.number().describe("Start year"), 54 | start_month: z.number().describe("Start month"), 55 | end_year: z.number().describe("End year (0 if ongoing)"), 56 | end_month: z.number().describe("End month (0 if ongoing)"), 57 | description: z 58 | .string() 59 | .default("") 60 | .describe("Detailed description of the experience (Markdown format)"), 61 | } as const; 62 | 63 | /** 64 | * Execute function 65 | */ 66 | async execute(args: InferZodParams): Promise<{ 67 | content: TextContent[]; 68 | isError?: boolean; 69 | }> { 70 | const apiKeyResult = validateApiKey(); 71 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 72 | 73 | try { 74 | const response = await fetch(new URL(`${BASE_URL}/experiences`), { 75 | method: "POST", 76 | headers: { 77 | accept: "application/json, text/plain, */*", 78 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 79 | }, 80 | body: JSON.stringify({ 81 | ...args, 82 | description: unescapeText(args.description), 83 | }), 84 | }); 85 | 86 | if (!response.ok) { 87 | throw new Error(`API request failed with status: ${response.status}`); 88 | } 89 | 90 | const data = await response.json(); 91 | return { 92 | content: [{ type: "text", text: JSON.stringify(data, null, 2) }], 93 | }; 94 | } catch (error) { 95 | console.error(error); 96 | return createErrorResponse(error, "職歴の新規追加に失敗しました"); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/tools/updateExperience.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { z } from "zod"; 4 | import { BASE_URL } from "../constants.js"; 5 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 6 | import { unescapeText } from "../helpers/textFormatter.js"; 7 | import { validateApiKey } from "../helpers/validateApiKey.js"; 8 | import type { IMCPTool, InferZodParams } from "../types.js"; 9 | 10 | /** 11 | * 職歴更新ツール 12 | */ 13 | export class UpdateExperienceTool implements IMCPTool { 14 | /** 15 | * Tool name 16 | */ 17 | readonly name = "update_experience"; 18 | 19 | /** 20 | * Tool description 21 | */ 22 | readonly description = 23 | "Update a work experience on LAPRAS(https://lapras.com). You can check the result at https://lapras.com/cv"; 24 | 25 | /** 26 | * Parameter definition 27 | */ 28 | readonly parameters = { 29 | experience_id: z.number().describe("ID of the experience to update"), 30 | organization_name: z.string().describe("Name of the organization"), 31 | positions: z 32 | .array( 33 | z.object({ 34 | id: z 35 | .number() 36 | .describe( 37 | "Position type ID (1: フロントエンドエンジニア, 2: バックエンドエンジニア, 3: Webアプリケーションエンジニア, 4: インフラエンジニア, 5: SRE, 6: Android アプリエンジニア, 7: iOS アプリエンジニア, 8: モバイルエンジニア, 9: 機械学習エンジニア, 10: データサイエンティスト, 11: プロジェクトマネージャー, 12: プロダクトマネージャー, 13: テックリード, 14: エンジニアリングマネージャー, 15: リサーチエンジニア, 16: QA・テストエンジニア, 17: アーキテクト, 18: システムエンジニア, 19: 組み込みエンジニア, 20: データベースエンジニア, 21: ネットワークエンジニア, 22: セキュリティエンジニア, 23: スクラムマスター, 24: ゲームエンジニア, 25: CTO, 26: コーポレートエンジニア, 27: デザイナーその他, 28: データエンジニア, 29: CRE・テクニカルサポート, 30: セールスエンジニア・プリセールス, 32: ITエンジニアその他, 33: UI/UXデザイナー, 34: Webデザイナー, 35: ゲームデザイナー, 36: 動画制作・編集, 37: Webプロデューサー・ディレクター, 38: Webコンテンツ企画・編集・ライティング, 39: ゲームプロデューサー・ディレクター, 40: プロダクトマーケティングマネージャー, 41: 動画プロデューサー・ディレクター, 42: アートディレクター, 43: PM/ディレクターその他, 44: 営業, 45: 法人営業, 46: 個人営業, 47: 営業企画, 48: 営業事務, 49: 代理店営業, 50: インサイドセールス, 51: セールスその他, 52: 事業企画, 53: 経営企画, 54: 新規事業開発, 55: 事業開発その他, 56: カスタマーサクセス, 57: カスタマーサポート, 58: ヘルプデスク, 59: コールセンター管理・運営, 60: カスタマーサクセス・サポートその他, 61: 広報・PR・広告宣伝, 62: リサーチ・データ分析, 63: 商品企画・開発, 64: 販促, 65: MD・VMD・バイヤー, 66: Web広告運用・SEO・SNS運用, 67: CRM, 68: 広報・マーケティングその他, 69: 経営者・CEO・COO等, 70: CFO, 71: CIO, 72: 監査役, 73: 経営・CXOその他, 74: 経理, 75: 財務, 76: 法務, 77: 総務, 78: 労務, 79: 秘書, 80: 事務, 81: コーポレートその他, 82: 採用, 83: 人材開発・人材育成・研修, 84: 制度企画・組織開発, 85: 労務・給与, 86: 人事その他, 87: システムコンサルタント, 88: パッケージ導入コンサルタント, 89: セキュリティコンサルタント, 90: ネットワークコンサルタント, 91: ITコンサルタントその他, 92: 戦略コンサルタント, 93: DXコンサルタント, 94: 財務・会計コンサルタント, 95: 組織・人事コンサルタント, 96: 業務プロセスコンサルタント, 97: 物流コンサルタント, 98: リサーチャー・調査員, 99: コンサルタントその他, 100: その他)", 38 | ), 39 | }), 40 | ) 41 | .describe( 42 | "List of position type IDs - multiple selections are allowed. Please set relevant position types.", 43 | ), 44 | position_name: z.string().default("").describe("Position title"), 45 | is_client_work: z 46 | .boolean() 47 | .describe( 48 | "Whether this is client work (Set to true when the affiliated company and the project client are different, such as in contract development companies)", 49 | ), 50 | client_company_name: z 51 | .string() 52 | .optional() 53 | .describe("Client company name (required only when is_client_work is true)"), 54 | start_year: z.number().describe("Start year"), 55 | start_month: z.number().describe("Start month"), 56 | end_year: z.number().describe("End year (0 if ongoing)"), 57 | end_month: z.number().describe("End month (0 if ongoing)"), 58 | description: z 59 | .string() 60 | .default("") 61 | .describe("Detailed description of the experience (Markdown format)"), 62 | } as const; 63 | 64 | /** 65 | * Execute function 66 | */ 67 | async execute(args: InferZodParams): Promise<{ 68 | content: TextContent[]; 69 | isError?: boolean; 70 | }> { 71 | const apiKeyResult = validateApiKey(); 72 | if (apiKeyResult.isInvalid) return apiKeyResult.errorResopnse; 73 | 74 | const { experience_id, ...updateData } = args; 75 | 76 | try { 77 | const response = await fetch(new URL(`${BASE_URL}/experiences/${experience_id}`), { 78 | method: "PUT", 79 | headers: { 80 | accept: "application/json, text/plain, */*", 81 | "Content-Type": "application/json", 82 | Authorization: `Bearer ${apiKeyResult.apiKey}`, 83 | }, 84 | body: JSON.stringify({ 85 | ...updateData, 86 | description: unescapeText(updateData.description), 87 | }), 88 | }); 89 | 90 | if (!response.ok) { 91 | throw new Error(`API request failed with status: ${response.status}`); 92 | } 93 | 94 | const data = await response.json(); 95 | return { 96 | content: [{ type: "text", text: JSON.stringify(data, null, 2) }], 97 | }; 98 | } catch (error) { 99 | console.error(error); 100 | return createErrorResponse(error, "職歴の更新に失敗しました"); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/tools/__tests__/createExperience.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { CreateExperienceTool } from "../createExperience.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("CreateExperienceTool", () => { 12 | let tool: CreateExperienceTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new CreateExperienceTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("職歴を正常に作成できる", async () => { 29 | const mockData = { 30 | id: "123", 31 | organization_name: "Test Company", 32 | positions: [{ id: 1 }], 33 | start_year: 2020, 34 | start_month: 1, 35 | end_year: 2023, 36 | end_month: 12, 37 | }; 38 | 39 | const createParams = { 40 | organization_name: "Test Company", 41 | positions: [{ id: 1 }], 42 | is_client_work: false, 43 | start_year: 2020, 44 | start_month: 1, 45 | end_year: 2023, 46 | end_month: 12, 47 | position_name: "", 48 | client_company_name: "", 49 | description: "", 50 | }; 51 | 52 | mockFetch.mockResolvedValueOnce({ 53 | ok: true, 54 | json: () => Promise.resolve(mockData), 55 | }); 56 | 57 | const result = await tool.execute(createParams); 58 | 59 | expect(result.isError).toBeUndefined(); 60 | expect(result.content[0].type).toBe("text"); 61 | expect(JSON.parse(result.content[0].text)).toEqual(mockData); 62 | 63 | expect(mockFetch).toHaveBeenCalledWith( 64 | expect.any(URL), 65 | expect.objectContaining({ 66 | method: "POST", 67 | headers: { 68 | accept: "application/json, text/plain, */*", 69 | Authorization: "Bearer test-api-key", 70 | }, 71 | body: JSON.stringify(createParams), 72 | }), 73 | ); 74 | }); 75 | 76 | it("クライアントワークの場合、client_company_nameが必須", async () => { 77 | const createParams = { 78 | organization_name: "Test Company", 79 | positions: [{ id: 1 }], 80 | is_client_work: true, 81 | start_year: 2020, 82 | start_month: 1, 83 | end_year: 2023, 84 | end_month: 12, 85 | position_name: "", 86 | client_company_name: "Client Company", 87 | description: "", 88 | }; 89 | 90 | mockFetch.mockResolvedValueOnce({ 91 | ok: true, 92 | json: () => Promise.resolve({ ...createParams, id: "123" }), 93 | }); 94 | 95 | const result = await tool.execute(createParams); 96 | 97 | expect(result.isError).toBeUndefined(); 98 | expect(mockFetch).toHaveBeenCalledWith( 99 | expect.any(URL), 100 | expect.objectContaining({ 101 | method: "POST", 102 | body: JSON.stringify(createParams), 103 | }), 104 | ); 105 | }); 106 | 107 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 108 | process.env.LAPRAS_API_KEY = undefined; 109 | 110 | const result = await tool.execute({ 111 | organization_name: "Test Company", 112 | positions: [{ id: 1 }], 113 | is_client_work: false, 114 | start_year: 2020, 115 | start_month: 1, 116 | end_year: 2023, 117 | end_month: 12, 118 | position_name: "", 119 | client_company_name: "", 120 | description: "", 121 | }); 122 | 123 | expect(result.isError).toBe(true); 124 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 125 | }); 126 | 127 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 128 | const originalConsoleError = console.error; 129 | console.error = vi.fn(); 130 | 131 | mockFetch.mockResolvedValueOnce({ 132 | ok: false, 133 | status: 400, 134 | }); 135 | 136 | const result = await tool.execute({ 137 | organization_name: "Test Company", 138 | positions: [{ id: 1 }], 139 | is_client_work: false, 140 | start_year: 2020, 141 | start_month: 1, 142 | end_year: 2023, 143 | end_month: 12, 144 | position_name: "", 145 | client_company_name: "", 146 | description: "", 147 | }); 148 | 149 | expect(result.isError).toBe(true); 150 | expect(result.content[0].text).toContain("職歴の新規追加に失敗しました"); 151 | expect(console.error).toHaveBeenCalled(); 152 | 153 | console.error = originalConsoleError; 154 | }); 155 | 156 | it("descriptionの\\nを改行文字に変換する", async () => { 157 | const createParams = { 158 | organization_name: "Test Company", 159 | positions: [{ id: 1 }], 160 | is_client_work: false, 161 | start_year: 2020, 162 | start_month: 1, 163 | end_year: 2023, 164 | end_month: 12, 165 | position_name: "", 166 | client_company_name: "", 167 | description: "Line 1\\nLine 2\\nLine 3", 168 | }; 169 | 170 | const expectedBody = { 171 | ...createParams, 172 | description: "Line 1\nLine 2\nLine 3", 173 | }; 174 | 175 | mockFetch.mockResolvedValueOnce({ 176 | ok: true, 177 | json: () => Promise.resolve({ ...expectedBody, id: "123" }), 178 | }); 179 | 180 | const result = await tool.execute(createParams); 181 | 182 | expect(result.isError).toBeUndefined(); 183 | expect(mockFetch).toHaveBeenCalledWith( 184 | expect.any(URL), 185 | expect.objectContaining({ 186 | method: "POST", 187 | body: JSON.stringify(expectedBody), 188 | }), 189 | ); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LAPRAS MCP Server 2 | 3 | https://lapras.com 公式のMCP Server 4 | 5 | [![npm version](https://img.shields.io/npm/v/@lapras-inc/lapras-mcp-server.svg)](https://www.npmjs.com/package/@lapras-inc/lapras-mcp-server) 6 | [![npm downloads](https://img.shields.io/npm/dt/@lapras-inc/lapras-mcp-server.svg)](https://www.npmjs.com/package/@lapras-inc/lapras-mcp-server) 7 | [![Docker Pulls](https://img.shields.io/docker/pulls/laprascom/lapras-mcp-server)](https://hub.docker.com/r/laprascom/lapras-mcp-server) 8 | [![CI Status](https://img.shields.io/github/actions/workflow/status/lapras-inc/lapras-mcp-server/ci.yml?branch=main)](https://github.com/lapras-inc/lapras-mcp-server/actions) 9 | 10 | 11 | ## Setup 12 | 13 | MCP Serverの設定([Cursor](https://docs.cursor.com/context/model-context-protocol#configuring-mcp-servers)、[Claude Desktop](https://modelcontextprotocol.io/quickstart/user))を参考に、mcp.jsonまたはclaude_desktop_config.jsonに以下を追記してください。 14 | LAPRAS_API_KEYは職歴関連のツールを使う場合のみ必要です。https://lapras.com/config/api-key から取得できます。 15 | 16 | ### Desktop Extension (DXT) 17 | 18 | Claude Desktopを使用している場合、Desktop Extension(.dxtファイル)を使って簡単にインストールできます。 19 | 20 | 1. [リリースページ](https://github.com/lapras-inc/lapras-mcp-server/releases)から最新の`lapras-mcp-server.dxt`をダウンロード 21 | 2. Claude Desktopの設定画面を開く 22 | 3. ダウンロードした`.dxt`ファイルを設定画面にドラッグ&ドロップ 23 | 4. 必要に応じてLAPRAS_API_KEYを設定(LAPRAS_API_KEYを設定後はMCPの有効・無効の設定をトグルしてください) 24 | 25 | ### npx 26 | 27 | ``` 28 | { 29 | "mcpServers": { 30 | "lapras": { 31 | "command": "npx", 32 | "args": [ 33 | "-y", 34 | "@lapras-inc/lapras-mcp-server" 35 | ], 36 | "env": { 37 | "LAPRAS_API_KEY": "" 38 | } 39 | } 40 | } 41 | } 42 | ``` 43 | 44 | > [!IMPORTANT] 45 | > Node.jsの環境によってはサーバー接続に失敗する可能性があります。その場合は下記のDocker経由での利用をお試しください。 46 | > また、WSL経由でnpxを実行する場合は、envの環境変数は読み取れません。argsで直接環境変数を指定する必要があります。 47 | > 例: `"args": ["LAPRAS_API_KEY=", "bash", "-c", "/home/bin/npx @lapras-inc/lapras-mcp-server"]` 48 | 49 | 50 | ### Docker 51 | 52 | ``` 53 | { 54 | "mcpServers": { 55 | "lapras": { 56 | "command": "docker", 57 | "args": [ 58 | "run", 59 | "-i", 60 | "--rm", 61 | "-e", 62 | "LAPRAS_API_KEY", 63 | "laprascom/lapras-mcp-server:v0.4.0" 64 | ], 65 | "env": { 66 | "LAPRAS_API_KEY": "" 67 | } 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | ### Gemini CLI 74 | 75 | GoogleのGemini CLIで利用する場合、上記の「**npx**」セクションにあるJSONコードを、お使いの`settings.json`ファイルに追記します。 76 | 77 | 設定ファイルは通常、OSごとに以下のパスに配置されています。 78 | * **Windows**: `C:\Users\<ユーザー名>\.gemini\` 79 | * **macOS / Linux**: `~/.gemini/` 80 | 81 | > [!NOTE] 82 | > `gemini.conf.toml` を使用する場合は、以下のTOML形式で記述することも可能です。 83 | > ```toml 84 | > [mcpServers.lapras] 85 | > command = "npx" 86 | > args = ["-y", "@lapras-inc/lapras-mcp-server"] 87 | > 88 | > [mcpServers.lapras.env] 89 | > LAPRAS_API_KEY = "" 90 | > ``` 91 | 92 | 93 | ## General notes 94 | > [!WARNING] 95 | > AIがMCPサーバー経由でLAPRASから取得した情報(個人情報等を含む)は、ご利用中のAIモデルに送信され、解釈・処理が行われます。 96 | > 利用されるAIサービスのデータ取扱いポリシー等をご確認の上、個人情報や機密情報の取り扱いにはご留意ください。 97 | 98 | ## Examples 99 | 100 | #### シンプルな求人の検索例 101 | 102 | ``` 103 | フルリモートワーク可能でRustが使えるバックエンドの求人を探してください。年収は800万以上で。 104 | 結果はMarkdownの表にまとめてください。 105 | ``` 106 | 107 | #### 自分にあった求人の検索例 108 | 109 | ``` 110 | <自分のキャリアがわかる画像 or URL を貼り付ける> 111 | これが私の職歴です。私に合いそうな求人を探してください。 112 | ``` 113 | 114 | #### 自分に合った求人の検索例 115 | 116 | ``` 117 | LAPRASで職歴を取得して、私に合いそうな求人を探してください。 118 | ``` 119 | 120 | #### 職歴を更新する例 121 | 122 | ``` 123 | <自分のキャリアがわかる画像 or URL を貼り付ける> 124 | これが私の職歴です。LARPASの職歴を更新してください。 125 | ``` 126 | 127 | #### LAPRASの職歴を改善する例 128 | 129 | ``` 130 | LAPRASの職歴を取得して、ブラッシュアップするための質問をしてください。 131 | 改善後、LAPRASの職歴を更新してください。 132 | ``` 133 | 134 | #### 職務要約を更新する例 135 | 136 | ``` 137 | 私のこれまでの職歴を整理し職務要約を作成して、LAPRASに登録してください。 138 | ``` 139 | 140 | #### 今後のキャリアでやりたいことを更新する例 141 | 142 | ``` 143 | 私の職歴を取得して、今後のキャリアでやりたいことについて質問してください。 144 | 回答をもとに、LAPRASの今後のキャリアでやりたいことを更新してください。 145 | ``` 146 | 147 | https://github.com/user-attachments/assets/9c61470f-f97d-4e6f-97ca-53718c796376 148 | 149 | ## Tools 150 | ### `search_job` 求人検索 151 | - キーワード、ページ番号、最低年収などのパラメータを使用して求人を検索 152 | - 使用例: `search_job` ツールを呼び出し、特定の条件に合致する求人リストを取得 153 | 154 | ### `get_job_detail` 求人詳細取得 155 | - 求人IDを指定して特定の求人の詳細情報を取得 156 | - 使用例: `get_job_detail` ツールを呼び出し、特定の求人の詳細情報を取得 157 | 158 | ### `get_experiences` 職歴一覧取得 159 | - LAPRASに登録されている職歴情報の一覧を取得 160 | - 使用例: `get_experiences` ツールを呼び出し、登録済みの職歴一覧を取得 161 | 162 | ### `create_experience` 職歴新規追加 163 | - LAPRASに新しい職歴情報を追加 164 | - 使用例: `create_experience` ツールを呼び出し、新しい職歴を登録 165 | 166 | ### `update_experience` 職歴更新 167 | - LAPRASに登録されている職歴情報を更新 168 | - 使用例: `update_experience` ツールを呼び出し、既存の職歴を更新 169 | 170 | ### `delete_experience` 職歴削除 171 | - LAPRASに登録されている職歴情報を削除 172 | - 使用例: `delete_experience` ツールを呼び出し、指定した職歴を削除 173 | 174 | ### `get_job_summary` 職務要約取得 175 | - LAPRASに登録されている職務要約を取得 176 | - 使用例: `get_job_summary` ツールを呼び出し、登録済みの職務要約を取得 177 | 178 | ### `update_job_summary` 職務要約更新 179 | - LAPRASに職務要約を登録または更新 180 | - 使用例: `update_job_summary` ツールを呼び出し、職務要約を更新 181 | 182 | ### `get_want_to_do` キャリア志向取得 183 | - LAPRASに登録されている今後のキャリアでやりたいことを取得 184 | - 使用例: `get_want_to_do` ツールを呼び出し、やりたいことを取得 185 | 186 | ### `update_want_to_do` キャリア志向更新 187 | - LAPRASに今後のキャリアでやりたいことを登録または更新 188 | - 使用例: `update_want_to_do` ツールを呼び出し、やりたいことを更新 189 | 190 | ### `get_tech_skill` テックスキル取得 191 | - LAPRASに登録されている経験技術・スキル・資格 192 | 一覧(スキルID・スキル名・経験年数)を取得 193 | - 使用例: `get_tech_skill` ツールを呼び出し、現在のテックスキルの状況を確認 194 | 195 | ### `update_tech_skill` テックスキル更新 196 | - 経験技術・スキル・資格と経験年数(実数値)を指定して、LAPRASのテックスキルを更新 197 | - 使用例: `update_tech_skill` ツールを呼び出し、抽出したスキル情報をLAPRASに反映 198 | 199 | > [!NOTE] 200 | > 職歴関連のツールを使用するには、LAPRAS_API_KEYの設定が必要です。 201 | > APIキーは https://lapras.com/config/api-key から取得できます。 202 | 203 | -------------------------------------------------------------------------------- /src/tools/__tests__/updateExperience.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { UpdateExperienceTool } from "../updateExperience.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("UpdateExperienceTool", () => { 12 | let tool: UpdateExperienceTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new UpdateExperienceTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("職歴を正常に更新できる", async () => { 29 | const mockData = { 30 | id: 123, 31 | organization_name: "Updated Company", 32 | positions: [{ id: 1 }], 33 | start_year: 2020, 34 | start_month: 1, 35 | end_year: 2023, 36 | end_month: 12, 37 | }; 38 | 39 | const updateParams = { 40 | experience_id: 123, 41 | organization_name: "Updated Company", 42 | positions: [{ id: 1 }], 43 | is_client_work: false, 44 | start_year: 2020, 45 | start_month: 1, 46 | end_year: 2023, 47 | end_month: 12, 48 | position_name: "", 49 | client_company_name: "", 50 | description: "", 51 | }; 52 | 53 | mockFetch.mockResolvedValueOnce({ 54 | ok: true, 55 | json: () => Promise.resolve(mockData), 56 | }); 57 | 58 | const result = await tool.execute(updateParams); 59 | 60 | expect(result.isError).toBeUndefined(); 61 | expect(result.content[0].type).toBe("text"); 62 | expect(JSON.parse(result.content[0].text)).toEqual(mockData); 63 | 64 | expect(mockFetch).toHaveBeenCalledWith( 65 | expect.any(URL), 66 | expect.objectContaining({ 67 | method: "PUT", 68 | headers: { 69 | accept: "application/json, text/plain, */*", 70 | "Content-Type": "application/json", 71 | Authorization: "Bearer test-api-key", 72 | }, 73 | body: JSON.stringify({ 74 | organization_name: updateParams.organization_name, 75 | positions: updateParams.positions, 76 | is_client_work: updateParams.is_client_work, 77 | start_year: updateParams.start_year, 78 | start_month: updateParams.start_month, 79 | end_year: updateParams.end_year, 80 | end_month: updateParams.end_month, 81 | position_name: updateParams.position_name, 82 | client_company_name: updateParams.client_company_name, 83 | description: updateParams.description, 84 | }), 85 | }), 86 | ); 87 | }); 88 | 89 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 90 | process.env.LAPRAS_API_KEY = undefined; 91 | 92 | const result = await tool.execute({ 93 | experience_id: 123, 94 | organization_name: "Test Company", 95 | positions: [{ id: 1 }], 96 | is_client_work: false, 97 | start_year: 2020, 98 | start_month: 1, 99 | end_year: 2023, 100 | end_month: 12, 101 | position_name: "", 102 | client_company_name: "", 103 | description: "", 104 | }); 105 | 106 | expect(result.isError).toBe(true); 107 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 108 | }); 109 | 110 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 111 | const originalConsoleError = console.error; 112 | console.error = vi.fn(); 113 | 114 | mockFetch.mockResolvedValueOnce({ 115 | ok: false, 116 | status: 400, 117 | }); 118 | 119 | const result = await tool.execute({ 120 | experience_id: 123, 121 | organization_name: "Test Company", 122 | positions: [{ id: 1 }], 123 | is_client_work: false, 124 | start_year: 2020, 125 | start_month: 1, 126 | end_year: 2023, 127 | end_month: 12, 128 | position_name: "", 129 | client_company_name: "", 130 | description: "", 131 | }); 132 | 133 | expect(result.isError).toBe(true); 134 | expect(result.content[0].text).toContain("職歴の更新に失敗しました"); 135 | expect(console.error).toHaveBeenCalled(); 136 | 137 | console.error = originalConsoleError; 138 | }); 139 | 140 | it("descriptionの\\nを改行文字に変換する", async () => { 141 | const updateParams = { 142 | experience_id: 123, 143 | organization_name: "Test Company", 144 | positions: [{ id: 1 }], 145 | is_client_work: false, 146 | start_year: 2020, 147 | start_month: 1, 148 | end_year: 2023, 149 | end_month: 12, 150 | position_name: "", 151 | client_company_name: "", 152 | description: "Line 1\\nLine 2\\nLine 3", 153 | }; 154 | 155 | const expectedBody = { 156 | organization_name: updateParams.organization_name, 157 | positions: updateParams.positions, 158 | is_client_work: updateParams.is_client_work, 159 | start_year: updateParams.start_year, 160 | start_month: updateParams.start_month, 161 | end_year: updateParams.end_year, 162 | end_month: updateParams.end_month, 163 | position_name: updateParams.position_name, 164 | client_company_name: updateParams.client_company_name, 165 | description: "Line 1\nLine 2\nLine 3", 166 | }; 167 | 168 | mockFetch.mockResolvedValueOnce({ 169 | ok: true, 170 | json: () => Promise.resolve({ ...expectedBody, id: 123 }), 171 | }); 172 | 173 | const result = await tool.execute(updateParams); 174 | 175 | expect(result.isError).toBeUndefined(); 176 | expect(mockFetch).toHaveBeenCalledWith( 177 | expect.any(URL), 178 | expect.objectContaining({ 179 | method: "PUT", 180 | body: JSON.stringify(expectedBody), 181 | }), 182 | ); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /src/tools/__tests__/updateTechSkill.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { UpdateTechSkillTool } from "../updateTechSkill.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("UpdateTechSkillTool", () => { 12 | let tool: UpdateTechSkillTool; 13 | let mockFetch: ReturnType; 14 | const originalEnv = process.env; 15 | 16 | beforeEach(() => { 17 | tool = new UpdateTechSkillTool(); 18 | mockFetch = fetch as unknown as ReturnType; 19 | process.env = { ...originalEnv, LAPRAS_API_KEY: "test-api-key" }; 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.resetAllMocks(); 25 | process.env = originalEnv; 26 | }); 27 | 28 | it("テックスキルを正常に更新できる", async () => { 29 | mockFetch 30 | .mockResolvedValueOnce({ 31 | ok: true, 32 | json: () => 33 | Promise.resolve({ 34 | tech_skill_list: [ 35 | { id: 1, name: "Python" }, 36 | { id: 2, name: "Go" }, 37 | ], 38 | }), 39 | }) 40 | .mockResolvedValueOnce({ 41 | ok: true, 42 | json: () => Promise.resolve({ error: false }), 43 | }); 44 | 45 | const result = await tool.execute({ 46 | tech_skill_list: [ 47 | { name: "Python", years: 4 }, 48 | { name: "Go", years: 1 }, 49 | ], 50 | }); 51 | 52 | expect(result.isError).toBeUndefined(); 53 | expect(mockFetch).toHaveBeenCalledTimes(2); 54 | expect(mockFetch).toHaveBeenNthCalledWith( 55 | 1, 56 | expect.any(URL), 57 | expect.objectContaining({ 58 | method: "GET", 59 | headers: { 60 | accept: "application/json, text/plain, */*", 61 | Authorization: "Bearer test-api-key", 62 | }, 63 | }), 64 | ); 65 | 66 | expect(mockFetch).toHaveBeenNthCalledWith( 67 | 2, 68 | expect.any(URL), 69 | expect.objectContaining({ 70 | method: "PUT", 71 | headers: { 72 | accept: "application/json, text/plain, */*", 73 | "Content-Type": "application/json", 74 | Authorization: "Bearer test-api-key", 75 | }, 76 | body: JSON.stringify({ 77 | tech_skill_list: [ 78 | { tech_skill_id: 1, years: 3 }, 79 | { tech_skill_id: 2, years: 1 }, 80 | ], 81 | }), 82 | }), 83 | ); 84 | }); 85 | 86 | it("経験年数の入力値が適切な経験年数IDに変換される", async () => { 87 | mockFetch 88 | .mockResolvedValueOnce({ 89 | ok: true, 90 | json: () => 91 | Promise.resolve({ 92 | tech_skill_list: [ 93 | { id: 10, name: "Skill Zero" }, 94 | { id: 20, name: "Skill One" }, 95 | { id: 30, name: "Skill Two" }, 96 | { id: 40, name: "Skill Three" }, 97 | { id: 50, name: "Skill Five" }, 98 | { id: 60, name: "Skill Ten" }, 99 | ], 100 | }), 101 | }) 102 | .mockResolvedValueOnce({ 103 | ok: true, 104 | json: () => Promise.resolve({ error: false }), 105 | }); 106 | 107 | const result = await tool.execute({ 108 | tech_skill_list: [ 109 | { name: "skill zero", years: 0 }, 110 | { name: "Skill One", years: 1 }, 111 | { name: "Skill Two", years: 2 }, 112 | { name: "Skill Three", years: 3 }, 113 | { name: " Skill Five ", years: 5 }, 114 | { name: "SKILL TEN", years: 10 }, 115 | ], 116 | }); 117 | 118 | expect(result.isError).toBeUndefined(); 119 | expect(mockFetch).toHaveBeenNthCalledWith( 120 | 2, 121 | expect.any(URL), 122 | expect.objectContaining({ 123 | method: "PUT", 124 | headers: expect.objectContaining({ 125 | "Content-Type": "application/json", 126 | }), 127 | body: JSON.stringify({ 128 | tech_skill_list: [ 129 | { tech_skill_id: 10, years: 0 }, 130 | { tech_skill_id: 20, years: 1 }, 131 | { tech_skill_id: 30, years: 2 }, 132 | { tech_skill_id: 40, years: 3 }, 133 | { tech_skill_id: 50, years: 5 }, 134 | { tech_skill_id: 60, years: 10 }, 135 | ], 136 | }), 137 | }), 138 | ); 139 | }); 140 | 141 | it("スキル名がマスターに存在しない場合はエラーを返す", async () => { 142 | mockFetch.mockResolvedValueOnce({ 143 | ok: true, 144 | json: () => 145 | Promise.resolve({ 146 | tech_skill_list: [{ id: 1, name: "Python" }], 147 | }), 148 | }); 149 | 150 | const result = await tool.execute({ 151 | tech_skill_list: [{ name: "Unknown", years: 2 }], 152 | }); 153 | 154 | expect(result.isError).toBe(true); 155 | expect(result.content[0].text).toContain("有効なテックスキルが存在しません"); 156 | expect(mockFetch).toHaveBeenCalledTimes(1); 157 | }); 158 | 159 | it("LAPRAS_API_KEYが設定されていない場合はエラーを返す", async () => { 160 | process.env.LAPRAS_API_KEY = undefined; 161 | 162 | const result = await tool.execute({ 163 | tech_skill_list: [{ name: "Python", years: 2 }], 164 | }); 165 | 166 | expect(result.isError).toBe(true); 167 | expect(result.content[0].text).toContain("LAPRAS_API_KEYの設定が必要です"); 168 | }); 169 | 170 | it("マスタ取得でエラーが発生した場合はエラーを返す", async () => { 171 | const originalConsoleError = console.error; 172 | console.error = vi.fn(); 173 | 174 | mockFetch.mockResolvedValueOnce({ 175 | ok: false, 176 | status: 500, 177 | json: () => Promise.resolve({}), 178 | }); 179 | 180 | const result = await tool.execute({ 181 | tech_skill_list: [{ name: "Python", years: 2 }], 182 | }); 183 | 184 | expect(result.isError).toBe(true); 185 | expect(result.content[0].text).toContain("テックスキルの更新に失敗しました"); 186 | 187 | console.error = originalConsoleError; 188 | }); 189 | 190 | it("更新APIでエラーが発生した場合はエラーを返す", async () => { 191 | const originalConsoleError = console.error; 192 | console.error = vi.fn(); 193 | 194 | mockFetch 195 | .mockResolvedValueOnce({ 196 | ok: true, 197 | json: () => 198 | Promise.resolve({ 199 | tech_skill_list: [{ id: 1, name: "Python" }], 200 | }), 201 | }) 202 | .mockResolvedValueOnce({ 203 | ok: false, 204 | status: 400, 205 | json: () => Promise.resolve({}), 206 | }); 207 | 208 | const result = await tool.execute({ 209 | tech_skill_list: [{ name: "Python", years: 2 }], 210 | }); 211 | 212 | expect(result.isError).toBe(true); 213 | expect(result.content[0].text).toContain("テックスキルの更新に失敗しました"); 214 | 215 | console.error = originalConsoleError; 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /src/tools/searchJobs.ts: -------------------------------------------------------------------------------- 1 | import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import fetch from "node-fetch"; 3 | import { z } from "zod"; 4 | import { BASE_URL } from "../constants.js"; 5 | import { createErrorResponse } from "../helpers/createErrorResponse.js"; 6 | import type { IMCPTool, InferZodParams } from "../types.js"; 7 | 8 | export const JobSearchResultSchema = z.object({ 9 | job_description_id: z.number(), 10 | company_id: z.number(), 11 | title: z.string(), 12 | created_at: z.number(), 13 | updated_at: z.number(), 14 | service_image_url: z.string().optional(), 15 | company: z.object({ 16 | name: z.string(), 17 | logo_image_url: z.string().optional(), 18 | }), 19 | work_location_prefecture: z.array(z.string()), 20 | position_name: z.string().optional(), 21 | tags: z 22 | .array( 23 | z.object({ 24 | name: z.string(), 25 | }), 26 | ) 27 | .optional(), 28 | employment_type: z.string().optional(), 29 | salary_min: z.number().optional(), 30 | salary_max: z.number().optional(), 31 | service_image_thumbnail_url: z.string().optional(), 32 | salary_type: z.number().optional(), 33 | preferred_condition_names: z.array(z.string()).optional(), 34 | business_type_names: z.array(z.string()).optional(), 35 | work_style_names: z.array(z.string()).optional(), 36 | url: z.string(), 37 | }); 38 | 39 | export type JobSearchResult = z.infer; 40 | 41 | /** 42 | * 求人検索ツール 43 | */ 44 | export class SearchJobsTool implements IMCPTool { 45 | /** 46 | * Tool name 47 | */ 48 | readonly name = "search_jobs"; 49 | 50 | /** 51 | * Tool description 52 | */ 53 | readonly description = "Search job by keyword, position, and minimum annual salary"; 54 | 55 | /** 56 | * Parameter definition 57 | */ 58 | readonly parameters = { 59 | keyword: z.string().optional().describe("The keyword to search for in job listings"), 60 | page: z.number().optional().describe("Page number for pagination"), 61 | positions: z 62 | .array(z.string()) 63 | .optional() 64 | .describe( 65 | "List of job position keys (e.g., FRONTEND_ENGINEER, BACKEND_ENGINEER, WEB_APPLICATION_ENGINEER, INFRA_ENGINEER, SITE_RELIABILITY_ENGINEER, ANDROID_ENGINEER, IOS_ENGINEER, MOBILE_ENGINEER, MACHINE_LEARNING_ENGINEER, DATA_SCIENTIST, PROJECT_MANAGER, PRODUCT_MANAGER, TECH_LEAD, ENGINEERING_MANAGER, RESEARCH_ENGINEER, TEST_ENGINEER, SOFTWARE_ARCHITECT, SYSTEM_ENGINEER, EMBEDDED_ENGINEER, DATABASE_ENGINEER, NETWORK_ENGINEER, SECURITY_ENGINEER, SCRUM_MASTER, GAME_ENGINEER, CTO, CORPORATE_ENGINEER, DESIGNER, DATA_ENGINEER, OTHER)", 66 | ), 67 | prog_lang_ids: z 68 | .array(z.number()) 69 | .optional() 70 | .describe( 71 | "List of programming language IDs (3: TypeScript, 39: JavaScript, 5: Python, 32: Go, 2: Ruby, 25: PHP, 45: Java, 40: Kotlin, 27: Node.js, 43: Swift, 82: Scala, 421: C#, 46: Rust, 56: C++, 42: Dart, 55: Objective-C)", 72 | ), 73 | framework_ids: z 74 | .array(z.number()) 75 | .optional() 76 | .describe( 77 | "List of framework IDs (4: Vue.js, 1428: React, 20: Next.js, 31: Nuxt.js, 6: Angular, 172: Redux, 21: Ruby on Rails, 76: Laravel, 140: Spring Boot, 8: Django, 237: Express, 41: Flutter, 171: ReactNative)", 78 | ), 79 | db_ids: z 80 | .array(z.number()) 81 | .optional() 82 | .describe( 83 | "List of database IDs (28: MySQL, 10: PostgreSQL, 419: SQL Server, 318: Oracle, 33: Aurora, 60: Redis, 221: DynamoDB, 170: MongoDB, 169: Elasticsearch, 200: BigQuery)", 84 | ), 85 | infra_ids: z 86 | .array(z.number()) 87 | .optional() 88 | .describe( 89 | "List of infrastructure and CI/CD IDs (15: AWS, 52: GCP, 165: Azure, 18: Docker, 17: Terraform, 224: Kubernetes, 51: Firebase, 16: CircleCI, 122: Jenkins, 180: GitHubActions)", 90 | ), 91 | business_types: z 92 | .array(z.number()) 93 | .optional() 94 | .describe("List of business type IDs (1: 自社開発, 2: 受託開発, 3: SES)"), 95 | employment_types: z 96 | .array(z.number()) 97 | .optional() 98 | .describe( 99 | "List of employment type IDs (1: 正社員, 2: 業務委託, 3: インターンシップ, 4: その他)", 100 | ), 101 | work_styles: z 102 | .array(z.number()) 103 | .optional() 104 | .describe("List of work style IDs (1: フルリモート, 2: 一部リモート)"), 105 | preferred_condition_ids: z 106 | .array(z.number()) 107 | .optional() 108 | .describe( 109 | "List of preferred condition IDs (1: 副業OK, 2: 副業からのジョイン可, 3: SOあり, 4: BtoB, 5: BtoC, 6: 株式上場済み, 7: グローバル, 8: 残業平均20時間未満, 9: アジャイル開発, 10: 英語で書く・話す業務がある, 11: フレックス, 12: 役員以上にエンジニアがいる, 13: 育休取得実績あり, 14: 地方在住社員がいる, 15: スタートアップ, 16: 副業)", 110 | ), 111 | annual_salary_min: z.number().optional().describe("Minimum annual salary requirement in JPY"), 112 | sort_type: z 113 | .string() 114 | .optional() 115 | .describe( 116 | "Sort order (人気順: popularity_desc, 新着順: updated_at_desc, 年収が低い順: annual_salary_at_asc, 年収が高い順: annual_salary_at_desc)", 117 | ), 118 | } as const; 119 | 120 | /** 121 | * Execute function 122 | */ 123 | async execute(args: InferZodParams): Promise<{ 124 | content: TextContent[]; 125 | isError?: boolean; 126 | }> { 127 | const { 128 | keyword, 129 | page, 130 | positions, 131 | prog_lang_ids, 132 | framework_ids, 133 | db_ids, 134 | infra_ids, 135 | business_types, 136 | employment_types, 137 | work_styles, 138 | preferred_condition_ids, 139 | annual_salary_min, 140 | sort_type, 141 | } = args; 142 | 143 | const url = new URL(`${BASE_URL}/job_descriptions/search`); 144 | 145 | if (page) { 146 | url.searchParams.append("page", page.toString()); 147 | } 148 | 149 | if (keyword) { 150 | url.searchParams.append("keyword", keyword); 151 | } 152 | 153 | if (positions && positions.length > 0) { 154 | for (const position of positions) { 155 | url.searchParams.append("positions[]", position); 156 | } 157 | } 158 | 159 | if (prog_lang_ids && prog_lang_ids.length > 0) { 160 | for (const id of prog_lang_ids) { 161 | url.searchParams.append("prog_lang_ids[]", id.toString()); 162 | } 163 | } 164 | 165 | if (framework_ids && framework_ids.length > 0) { 166 | for (const id of framework_ids) { 167 | url.searchParams.append("framework_ids[]", id.toString()); 168 | } 169 | } 170 | 171 | if (db_ids && db_ids.length > 0) { 172 | for (const id of db_ids) { 173 | url.searchParams.append("db_ids[]", id.toString()); 174 | } 175 | } 176 | 177 | if (infra_ids && infra_ids.length > 0) { 178 | for (const id of infra_ids) { 179 | url.searchParams.append("infra_ids[]", id.toString()); 180 | } 181 | } 182 | 183 | if (business_types && business_types.length > 0) { 184 | for (const type of business_types) { 185 | url.searchParams.append("business_types[]", type.toString()); 186 | } 187 | } 188 | 189 | if (employment_types && employment_types.length > 0) { 190 | for (const type of employment_types) { 191 | url.searchParams.append("employment_types[]", type.toString()); 192 | } 193 | } 194 | 195 | if (work_styles && work_styles.length > 0) { 196 | for (const style of work_styles) { 197 | url.searchParams.append("work_styles[]", style.toString()); 198 | } 199 | } 200 | 201 | if (preferred_condition_ids && preferred_condition_ids.length > 0) { 202 | for (const id of preferred_condition_ids) { 203 | url.searchParams.append("preferred_condition_ids[]", id.toString()); 204 | } 205 | } 206 | 207 | if (annual_salary_min !== undefined) { 208 | url.searchParams.append("annual_salary_min", annual_salary_min.toString()); 209 | } 210 | 211 | if (sort_type) { 212 | url.searchParams.append("sort_type", sort_type); 213 | } 214 | 215 | try { 216 | const response = await fetch(url); 217 | 218 | if (!response.ok) { 219 | throw new Error(`API request failed with status: ${response.status}`); 220 | } 221 | 222 | const rawData = await response.json(); 223 | 224 | const ApiResponse = z.object({ 225 | job_descriptions: z.array(JobSearchResultSchema).catch([]), 226 | total_count: z.number(), 227 | current_page: z.number(), 228 | per_page: z.number(), 229 | total_pages: z.number(), 230 | }); 231 | 232 | const data = ApiResponse.parse(rawData); 233 | 234 | // 画像URLはコンテキスト長を圧迫するため除外 235 | const cleanedJobs = data.job_descriptions.map((job) => { 236 | const { service_image_url, service_image_thumbnail_url, company, tags, ...rest } = job; 237 | return { 238 | ...rest, 239 | tags: tags?.map((tag) => tag.name).join(", "), 240 | company: { 241 | name: company.name, 242 | }, 243 | }; 244 | }); 245 | 246 | const content: TextContent[] = [ 247 | { 248 | type: "text", 249 | text: JSON.stringify({ ...data, job_descriptions: cleanedJobs }, null, 2), 250 | }, 251 | ]; 252 | 253 | return { content }; 254 | } catch (error) { 255 | return createErrorResponse(error, "求人情報の取得に失敗しました"); 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/tools/__tests__/searchJobs.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { SearchJobsTool } from "../searchJobs.js"; 4 | 5 | vi.mock("node-fetch", () => { 6 | return { 7 | default: vi.fn(), 8 | }; 9 | }); 10 | 11 | describe("SearchJobsTool", () => { 12 | let tool: SearchJobsTool; 13 | let mockFetch: ReturnType; 14 | 15 | beforeEach(() => { 16 | tool = new SearchJobsTool(); 17 | mockFetch = fetch as unknown as ReturnType; 18 | vi.clearAllMocks(); 19 | }); 20 | 21 | afterEach(() => { 22 | vi.resetAllMocks(); 23 | }); 24 | 25 | it("基本的なパラメータで正常にAPIリクエストを実行できる", async () => { 26 | const mockData = { 27 | job_descriptions: [ 28 | { 29 | job_description_id: 123, 30 | company_id: 456, 31 | title: "Software Engineer", 32 | created_at: 1234567890, 33 | updated_at: 1234567890, 34 | company: { 35 | name: "LAPRAS Inc.", 36 | }, 37 | work_location_prefecture: ["Tokyo"], 38 | url: "https://example.com/job/123", 39 | }, 40 | ], 41 | total_count: 10, 42 | current_page: 1, 43 | per_page: 20, 44 | total_pages: 1, 45 | }; 46 | 47 | mockFetch.mockResolvedValueOnce({ 48 | ok: true, 49 | json: () => Promise.resolve(mockData), 50 | }); 51 | 52 | const result = await tool.execute({ keyword: "engineer" } as any); 53 | 54 | expect(result.isError).toBeUndefined(); 55 | expect(result.content[0].type).toBe("text"); 56 | expect(JSON.parse(result.content[0].text)).toEqual(mockData); 57 | 58 | const callUrl = mockFetch.mock.calls[0][0].toString(); 59 | expect(callUrl).toContain( 60 | "https://lapras.com/api/mcp/job_descriptions/search?keyword=engineer", 61 | ); 62 | }); 63 | 64 | it("複数のパラメータを使用して正常にAPIリクエストを実行できる", async () => { 65 | const mockData = { 66 | job_descriptions: [ 67 | { 68 | job_description_id: 456, 69 | company_id: 789, 70 | title: "Frontend Engineer", 71 | created_at: 1234567890, 72 | updated_at: 1234567890, 73 | company: { 74 | name: "LAPRAS Inc.", 75 | }, 76 | work_location_prefecture: ["Tokyo"], 77 | url: "https://example.com/job/456", 78 | }, 79 | ], 80 | total_count: 5, 81 | current_page: 1, 82 | per_page: 20, 83 | total_pages: 1, 84 | }; 85 | 86 | mockFetch.mockResolvedValueOnce({ 87 | ok: true, 88 | json: () => Promise.resolve(mockData), 89 | }); 90 | 91 | const params = { 92 | keyword: "frontend", 93 | page: 1, 94 | positions: ["FRONTEND_ENGINEER"], 95 | prog_lang_ids: [3], // TypeScript 96 | annual_salary_min: 6000000, 97 | sort_type: "popularity_desc", 98 | }; 99 | 100 | const result = await tool.execute(params as any); 101 | 102 | expect(result.isError).toBeUndefined(); 103 | expect(result.content[0].type).toBe("text"); 104 | expect(JSON.parse(result.content[0].text)).toEqual(mockData); 105 | 106 | const fetchCall = mockFetch.mock.calls[0][0].toString(); 107 | expect(fetchCall).toContain("keyword=frontend"); 108 | expect(fetchCall).toContain("page=1"); 109 | expect(fetchCall).toContain("positions%5B%5D=FRONTEND_ENGINEER"); 110 | expect(fetchCall).toContain("prog_lang_ids%5B%5D=3"); 111 | expect(fetchCall).toContain("annual_salary_min=6000000"); 112 | expect(fetchCall).toContain("sort_type=popularity_desc"); 113 | }); 114 | 115 | it("すべての配列パラメータを正しく処理できる", async () => { 116 | const mockData = { 117 | job_descriptions: [ 118 | { 119 | job_description_id: 789, 120 | company_id: 101, 121 | title: "Full Stack Engineer", 122 | created_at: 1234567890, 123 | updated_at: 1234567890, 124 | company: { 125 | name: "LAPRAS Inc.", 126 | }, 127 | work_location_prefecture: ["Tokyo"], 128 | url: "https://example.com/job/789", 129 | }, 130 | ], 131 | total_count: 3, 132 | current_page: 1, 133 | per_page: 20, 134 | total_pages: 1, 135 | }; 136 | mockFetch.mockResolvedValueOnce({ 137 | ok: true, 138 | json: () => Promise.resolve(mockData), 139 | }); 140 | 141 | const params = { 142 | positions: ["FRONTEND_ENGINEER", "BACKEND_ENGINEER"], 143 | prog_lang_ids: [3, 39], // TypeScript, JavaScript 144 | framework_ids: [4, 1428], // Vue.js, React 145 | db_ids: [28, 10], // MySQL, PostgreSQL 146 | infra_ids: [15, 52], // AWS, GCP 147 | business_types: [1, 2], // 自社開発, 受託開発 148 | employment_types: [1], // 正社員 149 | work_styles: [1, 2], // フルリモート, 一部リモート 150 | preferred_condition_ids: [1, 3], // 副業OK, SOあり 151 | }; 152 | 153 | const result = await tool.execute(params as any); 154 | 155 | expect(result.isError).toBeUndefined(); 156 | expect(result.content[0].type).toBe("text"); 157 | expect(JSON.parse(result.content[0].text)).toEqual(mockData); 158 | 159 | const fetchCall = mockFetch.mock.calls[0][0].toString(); 160 | expect(fetchCall).toContain("positions%5B%5D=FRONTEND_ENGINEER"); 161 | expect(fetchCall).toContain("positions%5B%5D=BACKEND_ENGINEER"); 162 | expect(fetchCall).toContain("prog_lang_ids%5B%5D=3"); 163 | expect(fetchCall).toContain("prog_lang_ids%5B%5D=39"); 164 | expect(fetchCall).toContain("framework_ids%5B%5D=4"); 165 | expect(fetchCall).toContain("framework_ids%5B%5D=1428"); 166 | expect(fetchCall).toContain("db_ids%5B%5D=28"); 167 | expect(fetchCall).toContain("db_ids%5B%5D=10"); 168 | expect(fetchCall).toContain("infra_ids%5B%5D=15"); 169 | expect(fetchCall).toContain("infra_ids%5B%5D=52"); 170 | expect(fetchCall).toContain("business_types%5B%5D=1"); 171 | expect(fetchCall).toContain("business_types%5B%5D=2"); 172 | expect(fetchCall).toContain("employment_types%5B%5D=1"); 173 | expect(fetchCall).toContain("work_styles%5B%5D=1"); 174 | expect(fetchCall).toContain("work_styles%5B%5D=2"); 175 | expect(fetchCall).toContain("preferred_condition_ids%5B%5D=1"); 176 | expect(fetchCall).toContain("preferred_condition_ids%5B%5D=3"); 177 | }); 178 | 179 | it("APIリクエストが失敗した場合はエラーを返す", async () => { 180 | const originalConsoleError = console.error; 181 | console.error = vi.fn(); 182 | 183 | mockFetch.mockResolvedValueOnce({ 184 | ok: false, 185 | status: 404, 186 | json: () => Promise.resolve({}), 187 | }); 188 | 189 | const result = await tool.execute({ keyword: "engineer" } as any); 190 | expect(result.isError).toBe(true); 191 | expect(result.content[0].text).toContain("求人情報の取得に失敗しました"); 192 | 193 | console.error = originalConsoleError; 194 | }); 195 | 196 | it("ネットワークエラーが発生した場合は適切にエラーハンドリングする", async () => { 197 | const originalConsoleError = console.error; 198 | console.error = vi.fn(); 199 | 200 | const networkError = new Error("Network error"); 201 | mockFetch.mockRejectedValueOnce(networkError); 202 | 203 | const result = await tool.execute({ keyword: "engineer" } as any); 204 | expect(result.isError).toBe(true); 205 | expect(result.content[0].text).toContain("求人情報の取得に失敗しました"); 206 | 207 | console.error = originalConsoleError; 208 | }); 209 | 210 | it("APIレスポンスが正しくバリデーションされ、画像URLが除外される", async () => { 211 | const mockApiResponse = { 212 | job_descriptions: [ 213 | { 214 | job_description_id: 123, 215 | company_id: 456, 216 | title: "エンジニア募集", 217 | created_at: 1234567890, 218 | updated_at: 1234567890, 219 | service_image_url: "https://example.com/image.jpg", 220 | service_image_thumbnail_url: "https://example.com/thumbnail.jpg", 221 | company: { 222 | name: "LAPRAS Inc.", 223 | logo_image_url: "https://example.com/logo.jpg", 224 | }, 225 | work_location_prefecture: ["東京都"], 226 | position_name: "バックエンドエンジニア", 227 | tags: [{ name: "Python" }, { name: "Django" }], 228 | employment_type: "正社員", 229 | salary_min: 5000000, 230 | salary_max: 8000000, 231 | salary_type: 1, 232 | preferred_condition_names: ["フレックス"], 233 | business_type_names: ["自社開発"], 234 | work_style_names: ["フルリモート"], 235 | url: "https://example.com/job/123", 236 | }, 237 | ], 238 | total_count: 1, 239 | current_page: 1, 240 | per_page: 20, 241 | total_pages: 1, 242 | }; 243 | 244 | mockFetch.mockResolvedValueOnce({ 245 | ok: true, 246 | json: () => Promise.resolve(mockApiResponse), 247 | }); 248 | 249 | const result = await tool.execute({} as any); 250 | 251 | expect(result.isError).toBeUndefined(); 252 | const parsedContent = JSON.parse(result.content[0].text); 253 | 254 | // 画像URLが除外されていることを確認 255 | expect(parsedContent.job_descriptions[0].service_image_url).toBeUndefined(); 256 | expect(parsedContent.job_descriptions[0].service_image_thumbnail_url).toBeUndefined(); 257 | expect(parsedContent.job_descriptions[0].company.logo_image_url).toBeUndefined(); 258 | 259 | // タグが文字列に変換されていることを確認 260 | expect(parsedContent.job_descriptions[0].tags).toBe("Python, Django"); 261 | 262 | // 必須フィールドが存在することを確認 263 | expect(parsedContent.job_descriptions[0].job_description_id).toBe(123); 264 | expect(parsedContent.job_descriptions[0].title).toBe("エンジニア募集"); 265 | expect(parsedContent.job_descriptions[0].company.name).toBe("LAPRAS Inc."); 266 | }); 267 | 268 | it("不正なAPIレスポンスの場合はエラーを返す", async () => { 269 | const invalidApiResponse = { 270 | job_descriptions: [ 271 | { 272 | // job_description_idが欠けている 273 | title: "エンジニア募集", 274 | company: { 275 | name: "LAPRAS Inc.", 276 | }, 277 | }, 278 | ], 279 | // total_countが欠けている 280 | current_page: 1, 281 | per_page: 20, 282 | total_pages: 1, 283 | }; 284 | 285 | mockFetch.mockResolvedValueOnce({ 286 | ok: true, 287 | json: () => Promise.resolve(invalidApiResponse), 288 | }); 289 | 290 | const result = await tool.execute({} as any); 291 | expect(result.isError).toBe(true); 292 | expect(result.content[0].text).toContain("求人情報の取得に失敗しました"); 293 | }); 294 | }); 295 | --------------------------------------------------------------------------------