こんにちは!コアシステム開発Gでテックリードをやっている古賀です。
MCP(Model Context Protocol)はAIエージェントと外部システムをつなぐための重要な仕組みとして注目を集めており、私も MCP Server を Cursor に繋いでみて利便性が高まることを実感しています。
一方で次のような悩みが出てきました。
- MCP の挙動を理解できていない → 使いこなせているか?
- 自分の用途に合った MCP Server が提供されていない → 自作できないか?
- 自社サービスの MCP Server を立てられないか → ビジネスチャンス!?
そこで、ドキュメントや MCP ライブラリ(mark3labs/mcp-go)のコードを参考に自分で MCP Server を構築したところ、1・2 の課題を解消できました。
この記事では、敢えてMCP ライブラリを使わずに1から MCP Server を構築することを通じて、同じ悩みを持つ人の助けになれば幸いです。
前提
- Protocol Version: 2025-03-26
- mark3labs/mcp-go: v0.26.0
- MCP Host: Cursor
Model Context Protocol (MCP) とは何か?
MCP はAIモデルと外部システムの間で情報のやり取りを行うためのルール(プロトコル)です。この標準化によって、AIモデルと外部システムが相互接続しやすくなります。
では、なぜ外部システムと連携する必要があるのでしょうか?
主な理由として次のような点が挙げられます
- 動的なデータにリアルタイムでアクセスできる(例:最新のタスク情報など)
- 必要なときに必要な情報だけを取得できる(プロンプトに全て書く必要がなくなる)
- 外部システムの操作が可能になる(エージェントからAPIを通じて実行指示を出せる)
MCPを活用することで、AIモデルを現実の業務や情報環境により適応させることができます。
MCP アーキテクチャ
MCP ホスト
AI モデルを搭載したアプリケーションです。例えば、Cursor や Claude Desktop などが該当します。これらのアプリケーションは、MCP クライアントを通じて外部データにアクセスします。MCP クライアント
MCP クライアントは、MCP ホスト内に組み込まれたコンポーネントで、MCP サーバとの通信を担当します。各クライアントは1つのMCPサーバと状態を持ったセッションを確立し、リクエストを送信して機能を利用します。MCP サーバ
MCP サーバは、特定のデータソースや機能へのアクセスを提供するサーバです。例えば、ファイルシステム、データベース、Web API などへのアクセスを扱うことができます。サーバはクライアントからのリクエストを処理し、必要なデータや機能を提供します。
MCPサーバーが提供する3つの機能
MCPサーバーは、AIモデルとのやりとりをより柔軟に拡張するために、3つの基本機能を提供します。これらは、モデルにコンテキストを与えたり、行動を起こさせたりするための仕組みです。
プロンプト(Prompts):モデルのふるまいを誘導するためのテンプレートや指示(例:定型の入力指示)
リソース(Resources):モデルに追加の文脈や情報を与えるための構造化データ(例:ファイルの内容)
ツール(Tools):モデルが外部と連携して実際にアクションを実行するための機能(例:APIへのPOSTリクエスト)
本記事では、この中でもツール機能を持った MCP Serverの実装に焦点を当てます。
ライフサイクル
MCP では、クライアントとサーバーの接続に対して明確なライフサイクルが定義されています。これにより、通信の整合性や機能のやり取り、状態の管理が正しく行われるようになっています。
下図は、MCP におけるクライアントとサーバー間の通信ライフサイクルを示しています。
初期化(Initialization) クライアントがサーバーと接続する際に、使用するプロトコルのバージョンや、互いに利用可能な機能の確認を行います。いわば「通信を始める前のすり合わせ」の段階です。
操作(Operation) メインとなるやり取りのフェーズです。クライアントは、MCP サーバーに対してリクエストを送り、サーバーは必要な処理を行って応答を返します。ここではツール機能を使ったやりとりについて記載しています。
終了(Shutdown) セッションを正常にクローズするための処理を行います。
MCPクライアントとサーバー間のすべてのメッセージは、JSON-RPC 2.0仕様に準拠する必要があります(参考)。
MCP Server を1から実装する
ツール機能を持った MCP Server を Go で実装していきます。
今回は例として、ドキュメント管理 SaaS の「esa」から指定した記事情報を取得するサーバーを構築します。
私が所属する組織ではナレッジを esa に蓄積しており、AI モデルにその情報へアクセスする能力を与えることで、より有用なアシスタントとして活用するのが目的です。
処理フローは次のようになります。
- Host に「<esa記事URL>を参考にコードを修正して」という指示を出す
- MCP Client が MCP Server のツール実行を呼び出す
- MCP Server が esa 記事取得APIを使って記事情報を取得する
- Host が取得した記事情報を元にコードを修正する
それでは、MCP Server の実装について順を追って説明していきます。
入出力処理
まずはじめに MCP Client からリクエストを受信し、レスポンスを返す処理を実装します。
MCP のトランスポート層は基本的に stdio と Streamable HTTP の2種類がありますが、今回は stdio を使用します。つまり、クライアントからのリクエストを標準入力として受信し、レスポンスを標準出力に書き込みます。
func main() { ctx := context.Background() reader := bufio.NewReader(os.Stdin) for { // 1. リクエストの読み込み request, err := readRequest(ctx, reader) fmt.Fprintf(os.Stderr, "[debug:request] \n %v \n", request) if err != nil { fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) return } if request == "\n" { fmt.Fprintln(os.Stderr, "Please Input Info") continue } // 2. リクエストのハンドリング response := handle(ctx, []byte(request)) if response == nil { continue } responseBytes, err := json.Marshal(response) if err != nil { fmt.Fprintf(os.Stderr, "Error marshalling response: %v\n", err) continue } // 3. レスポンスの出力 fmt.Fprintf(os.Stderr, "[debug:response]\n %v \n", string(responseBytes)) fmt.Fprintln(os.Stdout, string(responseBytes)) } } func readRequest(ctx context.Context, reader *bufio.Reader) (string, error) { readChan := make(chan string, 1) errChan := make(chan error, 1) done := make(chan struct{}) defer close(done) go func() { select { case <-done: return default: // ユーザが入力するまでブロック line, err := reader.ReadString('\n') if err != nil { select { case errChan <- err: case <-done: } return } select { case readChan <- line: case <-done: } } }() select { case <-ctx.Done(): return "", ctx.Err() case err := <-errChan: return "", err case line := <-readChan: return line, nil } }
「リクエストの読み込み → リクエストのハンドリング → レスポンスの出力」を for 文で繰り返しています。
- リクエストの読み込み:
readRequest()
でユーザの入力を待ち受ける形でブロックし、受け取った入力結果を返します。ブロックするものの、コンテキストが閉じられた時やエラーが発生した時には抜けられるように ゴルーチンと select 文を使っています。これは mcp-go の実装そのままのコードです。 - リクエストのハンドリング:後述します。
- レスポンスの出力:
os.Stdout
にレスポンスを書き込むことで MCP Client にレスポンスを送ります。標準出力をレスポンスとして使用する関係上、デバッグログは標準エラー出力(os.Stderr
)に書き込みます。
リクエストのハンドリング
MCP Client からのリクエストをパースし次の流れで処理を行います。
- JSON-RPC バージョンの互換性チェック
- リクエストIDの存在チェック
- リクエストメソッドに応じた処理の実行(initialization/Listing Tools/Calling Tools)
チェックに失敗した場合はエラーレスポンスを返します。
以降、initialization・Listing Tools・Calling Tools についてそれぞれ解説していきます。
func handle(ctx context.Context, msg []byte) *Response { response := &Response{JSONRPC: "2.0"} var baseRequestMessage struct { JSONRPC string `json:"jsonrpc"` Method MCPMethod `json:"method"` ID any `json:"id"` Result any `json:"result,omitempty"` } if err := json.Unmarshal(msg, &baseRequestMessage); err != nil { response.Error = &Error{ Code: PARSE_ERROR, Message: "Failed to parse message", } return response } response.ID = baseRequestMessage.ID // 1. JSON-RPC バージョンの互換性チェック if baseRequestMessage.JSONRPC != "2.0" { response.Error = &Error{ Code: INVALID_REQUEST, Message: "Invalid JSON-RPC version", } return response } // 2. リクエストIDの存在チェック if baseRequestMessage.ID == nil { fmt.Fprintf(os.Stderr, "[debug:notification]\n\n") return nil // 通知はレスポンスを返さない } // 3. リクエストメソッドに応じた処理の実行 switch baseRequestMessage.Method { // initialization case MethodInitialize: response.Result = map[string]interface{}{ "protocolVersion": "2025-03-26", "capabilities": map[string]interface{}{ "tools": map[string]bool{ "listChanged": true, }, }, "serverInfo": map[string]string{ "name": "get esa post server", "version": "1.0.0", }, } return response // toolsList case MethodToolsList: response.Result = map[string]interface{}{ "tools": []map[string]interface{}{ { "name": "get_esa_post", "description": "Gets the specified post from the esa API", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "post_number": map[string]string{ "description": "post number", "type": "number", }, "team_name": map[string]string{ "description": "team name", "type": "string", }, }, "required": []string{"post_number", "team_name"}, }, }, }, } return response // toolsCall case MethodToolsCall: var callToolRequest struct { Params struct { Name string `json:"name"` Arguments map[string]interface{} `json:"arguments,omitempty"` } `json:"params"` } if unmarshalErr := json.Unmarshal(msg, &callToolRequest); unmarshalErr != nil { response.Error = &Error{ Code: INVALID_REQUEST, Message: "Failed to parse tool call request", } return response } post, err := getEsaPost( ctx, callToolRequest.Params.Arguments["team_name"].(string), int(callToolRequest.Params.Arguments["post_number"].(float64)), ) if err != nil { response.Error = &Error{ Code: INTERNAL_ERROR, Message: err.Error(), } return response } response.Result = map[string]interface{}{ "content": []map[string]interface{}{ { "type": "text", "text": post, }, }, } return response default: response.Error = &Error{ Code: METHOD_NOT_FOUND, Message: fmt.Sprintf("Method %s not found", baseRequestMessage.Method), } return response } } type Response struct { JSONRPC string `json:"jsonrpc"` ID any `json:"id"` Result interface{} `json:"result,omitempty"` Error *Error `json:"error,omitempty"` }
initialization
初期化フェーズは、クライアントとサーバー間の最初のやり取りです。このフェーズでは、プロトコルバージョンの互換性を確立したり、互いが持つ機能を確認したりします。
MCP クライアントからは次のようなリクエストが送信されます。
{ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": { "tools": true, "prompts": false, "resources": true, "logging": false, "roots": { "listChanged": false } }, "clientInfo": { "name": "cursor-vscode", "version": "1.0.0" } } }
サーバは次のようなレスポンスを返すように実装しています。
{ "jsonrpc": "2.0", "id": 0, "result": { "protocolVersion": "2025-03-26", "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": "get esa post server", "version": "1.0.0" } } }
protocolVersion
:プロトコルバージョンは2025-03-26
capabilities
:ツール機能を保有listChanged
は使用可能なツールのリストが変更されたときにサーバーが通知を発行するかどうかを提示。
serverInfo
:サーバ名、バージョン
初期化が成功した後、MCP クライアントから初期化通知が送信されます。
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
notification は一方向のメッセージとして送信されるもので、受信者は応答を返しません。
Listing Tools
初期化が完了した後、クライアントは利用可能なツールを見つけるために tools/list
リクエストを送信します。
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
サーバは提供可能なツールとそれを呼び出すのに必要な情報を返します。
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "get_esa_post", "description": "Gets the specified post from the esa API", "inputSchema": { "type": "object", "properties": { "post_number": { "description": "post number", "type": "number" }, "team_name": { "description": "team name", "type": "string" } }, "required": [ "post_number", "team_name" ] } } ] } }
name
:ツールの名前description
:ツールの説明properties
:クライアントがツール呼び出しを実行する時にサーバへ渡すパラメータ。記事取得APIを叩くために必要なteam_name
とpost_number
という2つのパラメータを定義しています。required
:ツールを使用するときに必須となるプロパティ
Listing Tools が完了したらツールを呼び出す準備は完了です。
Calling Tools
クライアントはtools/call リクエストを送信してツール呼び出しを依頼します。
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_esa_post", "arguments": { "post_number": 123, "team_name": "some_team" } } }
name
:呼び出すツール名arguments
:ツールを実行するためのパラメータ。tools/list レスポンスに含まれるものと同じものがここに入ってきます。
サーバはリクエストに含まれるパラメータを抽出して記事取得APIを叩き、取得した記事情報をレスポンスに含めて返します。
func getEsaPost(ctx context.Context, teamName string, postNumber int) (string, error) { token := os.Getenv("ESA_API_TOKEN") if token == "" { return "", errors.New("API TOKENが設定されていません") } req, err := http.NewRequestWithContext( ctx, "GET", fmt.Sprintf("https://api.esa.io/v1/teams/%s/posts/%d", teamName, postNumber), nil, ) if err != nil { return "", errors.New("リクエスト生成エラー: " + err.Error()) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", errors.New("esa API呼び出しエラー: " + err.Error()) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", errors.New("レスポンス読み込みエラー: " + err.Error()) } if resp.StatusCode != 200 { var apiErr struct { error string `json:"error"` message string `json:"message"` } _ = json.Unmarshal(body, &apiErr) return "", fmt.Errorf("esa APIエラー: %s (%s)", apiErr.error, apiErr.message) } var post struct { Name string `json:"name"` BodyMd string `json:"body_md"` } err = json.Unmarshal(body, &post) if err != nil { return "", fmt.Errorf("記事データのパースエラー: %w", err) } return fmt.Sprintf("# %s\n\n%s", post.Name, post.BodyMd), nil }
{ "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "text": "記事の内容がここに入ります", "type": "text" } ] } }
Cursor での設定
ここまでの全ソースコードはこのようになります。
package main import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" ) func main() { ctx := context.Background() reader := bufio.NewReader(os.Stdin) for { // 1. リクエストの読み込み request, err := readRequest(ctx, reader) fmt.Fprintf(os.Stderr, "[debug:request] \n %v \n", request) if err != nil { fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) return } if request == "\n" { fmt.Fprintln(os.Stderr, "Please Input Info") continue } // 2. リクエストのハンドリング response := handle(ctx, []byte(request)) if response == nil { continue } responseBytes, err := json.Marshal(response) if err != nil { fmt.Fprintf(os.Stderr, "Error marshalling response: %v\n", err) continue } // 3. レスポンスの出力 fmt.Fprintf(os.Stderr, "[debug:response]\n %v \n", string(responseBytes)) fmt.Fprintln(os.Stdout, string(responseBytes)) } } type MCPMethod string const ( MethodInitialize MCPMethod = "initialize" MethodToolsList MCPMethod = "tools/list" MethodToolsCall MCPMethod = "tools/call" ) const ( PARSE_ERROR = -32700 INVALID_REQUEST = -32600 METHOD_NOT_FOUND = -32601 INTERNAL_ERROR = -32603 ) func handle(ctx context.Context, msg []byte) *Response { response := &Response{JSONRPC: "2.0"} var baseRequestMessage struct { JSONRPC string `json:"jsonrpc"` Method MCPMethod `json:"method"` ID any `json:"id"` Result any `json:"result,omitempty"` } if err := json.Unmarshal(msg, &baseRequestMessage); err != nil { response.Error = &Error{ Code: PARSE_ERROR, Message: "Failed to parse message", } return response } response.ID = baseRequestMessage.ID // 1. JSON-RPC バージョンの互換性チェック if baseRequestMessage.JSONRPC != "2.0" { response.Error = &Error{ Code: INVALID_REQUEST, Message: "Invalid JSON-RPC version", } return response } // 2. リクエストIDの存在チェック if baseRequestMessage.ID == nil { fmt.Fprintf(os.Stderr, "[debug:notification]\n\n") return nil // 通知はレスポンスを返さない } // 3. リクエストメソッドに応じた処理の実行 switch baseRequestMessage.Method { // initialization case MethodInitialize: response.Result = map[string]interface{}{ "protocolVersion": "2025-03-26", "capabilities": map[string]interface{}{ "tools": map[string]bool{ "listChanged": true, }, }, "serverInfo": map[string]string{ "name": "get esa post server", "version": "1.0.0", }, } return response // toolsList case MethodToolsList: response.Result = map[string]interface{}{ "tools": []map[string]interface{}{ { "name": "get_esa_post", "description": "Gets the specified post from the esa API", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "post_number": map[string]string{ "description": "post number", "type": "number", }, "team_name": map[string]string{ "description": "team name", "type": "string", }, }, "required": []string{"post_number", "team_name"}, }, }, }, } return response // toolsCall case MethodToolsCall: var callToolRequest struct { Params struct { Name string `json:"name"` Arguments map[string]interface{} `json:"arguments,omitempty"` } `json:"params"` } if unmarshalErr := json.Unmarshal(msg, &callToolRequest); unmarshalErr != nil { response.Error = &Error{ Code: INVALID_REQUEST, Message: "Failed to parse tool call request", } return response } post, err := getEsaPost( ctx, callToolRequest.Params.Arguments["team_name"].(string), int(callToolRequest.Params.Arguments["post_number"].(float64)), ) if err != nil { response.Error = &Error{ Code: INTERNAL_ERROR, Message: err.Error(), } return response } response.Result = map[string]interface{}{ "content": []map[string]interface{}{ { "type": "text", "text": post, }, }, } return response default: response.Error = &Error{ Code: METHOD_NOT_FOUND, Message: fmt.Sprintf("Method %s not found", baseRequestMessage.Method), } return response } } func getEsaPost(ctx context.Context, teamName string, postNumber int) (string, error) { token := os.Getenv("ESA_API_TOKEN") if token == "" { return "", errors.New("API TOKENが設定されていません") } req, err := http.NewRequestWithContext( ctx, "GET", fmt.Sprintf("https://api.esa.io/v1/teams/%s/posts/%d", teamName, postNumber), nil, ) if err != nil { return "", errors.New("リクエスト生成エラー: " + err.Error()) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", errors.New("esa API呼び出しエラー: " + err.Error()) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", errors.New("レスポンス読み込みエラー: " + err.Error()) } if resp.StatusCode != 200 { var apiErr struct { error string `json:"error"` message string `json:"message"` } _ = json.Unmarshal(body, &apiErr) return "", fmt.Errorf("esa APIエラー: %s (%s)", apiErr.error, apiErr.message) } var post struct { Name string `json:"name"` BodyMd string `json:"body_md"` } err = json.Unmarshal(body, &post) if err != nil { return "", fmt.Errorf("記事データのパースエラー: %w", err) } return fmt.Sprintf("# %s\n\n%s", post.Name, post.BodyMd), nil } type Response struct { JSONRPC string `json:"jsonrpc"` ID any `json:"id"` Result interface{} `json:"result,omitempty"` Error *Error `json:"error,omitempty"` } type Error struct { Code int `json:"code"` Message string `json:"message"` } // readRequest reads a single line from the input reader in a context-aware manner. // It uses channels to make the read operation cancellable via context. // Returns the read line and any error encountered. If the context is cancelled, // returns an empty string and the context's error. EOF is returned when the input // stream is closed. func readRequest(ctx context.Context, reader *bufio.Reader) (string, error) { readChan := make(chan string, 1) errChan := make(chan error, 1) done := make(chan struct{}) defer close(done) go func() { select { case <-done: return default: // ユーザが入力するまでブロック line, err := reader.ReadString('\n') if err != nil { select { case errChan <- err: case <-done: } return } select { case readChan <- line: case <-done: } } }() select { case <-ctx.Done(): return "", ctx.Err() case err := <-errChan: return "", err case line := <-readChan: return line, nil } }
上記コードを main.go にまとめて次のようにビルドします。
go build -o esa-mcp-server
続いて mcp.json に MCP Server の設定を記載します。
{ "mcpServers": { "esa-mcp-server": { "command": "[your_path]/esa-mcp-server", "args": [], "env":{ "ESA_API_TOKEN": "{取得した esa api token}" }, "disabled":false, "autoApprove":[] } } }
command
にはビルド成果物であるバイナリファイルへのパスを記載し、env
には esa にアクセスするためのAPIトークンを設定します。
これで Cursor から作成した MCP Server を使って操作できるようになりました!
mcp-go を使うと簡単に実装できる
今回は MCP の仕組みを理解するために敢えて MCP ライブラリを使わずに実装しましたが、mcp-go を使って実装すると非常にシンプルに書けます。
package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) func main() { s := server.NewMCPServer( "esa記事取得サーバ", "1.0.0", server.WithRecovery(), ) // ツールの定義(tools/list で返す値) getEsaPostTool := mcp.NewTool("get_esa_post", mcp.WithDescription("Gets the specified post from the esa API"), mcp.WithString("team_name", mcp.Required(), mcp.Description("team name"), ), mcp.WithNumber("post_number", mcp.Required(), mcp.Description("post number"), ), ) // ツール実行時のアクションを定義 s.AddTool(getEsaPostTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { teamName := request.Params.Arguments["team_name"].(string) postNumber := int(request.Params.Arguments["post_number"].(float64)) token := os.Getenv("ESA_API_TOKEN") if token == "" { return nil, errors.New("API_TOKENが設定されていません") } url := fmt.Sprintf("https://api.esa.io/v1/teams/%s/posts/%d", teamName, postNumber) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, errors.New("リクエスト生成エラー: " + err.Error()) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, errors.New("esa API呼び出しエラー: " + err.Error()) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, errors.New("レスポンス読み込みエラー: " + err.Error()) } if resp.StatusCode != 200 { var apiErr struct { error string `json:"error"` message string `json:"message"` } _ = json.Unmarshal(body, &apiErr) return nil, errors.New(fmt.Sprintf("esa APIエラー: %s (%s)", apiErr.error, apiErr.message)) } var post struct { Name string `json:"name"` BodyMd string `json:"body_md"` } err = json.Unmarshal(body, &post) if err != nil { return nil, errors.New("記事データのパースエラー: " + err.Error()) } return mcp.NewToolResultText(fmt.Sprintf("# %s\n\n%s", post.Name, post.BodyMd)), nil }) // Start the server if err := server.ServeStdio(s); err != nil { fmt.Printf("Server error: %v\n", err) } }
ユーザ独自の実装(ツール定義とツール実行)にフォーカスでき、それ以外の部分はライブラリに任せることが可能になっています。
ここまでの内容を抑えていれば基本的な MCP の挙動は理解できているので、皆さんも MCP ライブラリのコードを読んで使えるようになっているはずです。
いざ自分で MCP Server を構築する際は、mcp-go を試してみてください。
まとめ
MCP Server を1から実装することで、MCP の理解を深めることができたと思います。 これをきっかけに MCP をより使いこなしてもらえると嬉しいです。
さいごに
ホワイトプラスでは、ビジョンやバリューに共感していただけるエンジニアを募集しています!
ネットクリーニングの「リネット」など、「生活領域×テクノロジー」で事業を展開しています。
弊社に興味がある方は、オウンドメディア「ホワプラSTYLE」をご覧ください。オンラインでのカジュアル面談も可能ですので、ぜひお気軽にお問い合わせください。