0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【#2】承認・履歴付 OSS SSH MCP server ssh-gate -- MCP 実装編

0
Last updated at Posted at 2026-07-08

SDK なしで Go から MCP サーバーを実装する — Streamable HTTP + JSON-RPC 2.0 を net/http だけで

連載「承認・履歴付 OSS SSH MCP server ssh-gate」第2回。



0. この記事のゴール

ssh-gete は「AI エージェントが要求した SSH コマンドを、人間が承認してから実行する」デスクトップアプリです。エージェント(Claude Desktop や Codex など)は MCP(Model Context Protocol) 経由でこのアプリにツール呼び出しを投げ、アプリ側は実行せずに承認キューへ積みます。実機を叩くのは、人間が GUI で承認ボタンを押した後だけ。

この第2回は、その入口になる MCP サーバー部分 を扱います。題材は1ファイル、mcp_server.go(約450行)です。

強調しておきたいのは、MCP の公式 SDK を一切使っていないことです。Go の標準ライブラリ(net/httpencoding/json)だけで、MCP のサーバーは普通に書けます。MCP の正体は「Streamable HTTP の上に乗った JSON-RPC 2.0」なので、仕様を読んでハンドラを1個書けば終わりです。

この記事を読むと、次のことが分かります。

  • MCP の最小単位(initialize / tools/list / tools/call)だけ実装すれば、エージェントから見えるサーバーは成立する
  • HTTP ハンドラのガード順序をどう設計し、なぜその順序なのか
  • ツール実行で「実行せずキューに積む」分岐をどこに入れるか
  • リスク分類のような便利だが信用できないヒューリスティックを、どう扱う(自動実行に使わない)か

そして最大の持ち帰りは、第0節で先に書いておきます。MCP サーバーの自作は難しくありません。難しいのは「ツールに何をさせないか」の設計です。


1. MCP の最小理解 — 実装すべきは3メソッドだけ

MCP は「LLM クライアントとツール提供サーバーの間の標準プロトコル」です。トランスポートにはいくつか種類がありますが、ssh-geteStreamable HTTP(HTTP の POST で JSON-RPC を1往復するスタイル)を採用しています。

クライアントとサーバーの会話は、突き詰めると次の3メソッドで回ります。

メソッド 役割 サーバーが返すもの
initialize 接続確立・能力交換 プロトコルバージョン、capabilities、serverInfo
tools/list 使えるツールの一覧 ツール名・説明・入力スキーマ(JSON Schema)
tools/call ツールを1個実行 content: [{type:"text", text:...}] 形式の結果

これに notifications/*(ID を持たない通知。返答不要)への対応を足せば、Claude Desktop のような一般的なクライアントとは問題なく繋がります。ssh-gete が実装しているのもこの範囲だけです。

JSON-RPC 2.0 のリクエストはこういう形です。

{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }

レスポンスは resulterror のどちらかを返します。error には JSON-RPC の標準コード(-32700 parse error、-32600 invalid request、-32601 method not found、-32602 invalid params など)を使います。これだけ把握すれば、もう書けます。


2. JSON-RPC の型定義(mcp_server.go:15

まず JSON-RPC のリクエスト・レスポンス・エラーを Go の構造体に落とします。

// mcp_server.go:15
type rpcRequest struct {
    JSONRPC string          `json:"jsonrpc"`
    ID      any             `json:"id,omitempty"`
    Method  string          `json:"method"`
    Params  json.RawMessage `json:"params,omitempty"`
}

type rpcResponse struct {
    JSONRPC string    `json:"jsonrpc"`
    ID      any       `json:"id,omitempty"`
    Result  any       `json:"result,omitempty"`
    Error   *rpcError `json:"error,omitempty"`
}

type rpcError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

ここで効いている設計判断が2つあります。

  • ID any:JSON-RPC の id は数値でも文字列でもよく、通知では存在しません。any にしておけば型を選ばず、そのままエコーバックできます。omitempty で通知応答時には消えます。
  • Params json.RawMessageparams の中身はメソッドごとに違います。ディスパッチの時点では中を見ず、生のバイト列のまま持ち回り、各ハンドラが必要になってから json.Unmarshal する。これで型分岐が1段ネストせずに済みます。

ツール呼び出しの引数も、必要な分だけ型を切ります。tools/call のパラメータと、実行系ツールの引数はこうです。

// mcp_server.go:38
type toolCallParams struct {
    Name      string          `json:"name"`
    Arguments json.RawMessage `json:"arguments"`  // ここも RawMessage
}

// mcp_server.go:43
type executeCommandArgs struct {
    Host        string `json:"host"`
    Command     string `json:"command"`
    Reason      string `json:"reason"`
    AgentName   string `json:"agent_name"`
    RequestedBy string `json:"requested_by"`   // agent_name の別名(後方互換)
    // ...
}

Argumentsjson.RawMessage です。tools/call は「ツール名でディスパッチしてから、そのツール固有の引数をデコード」という二段構えになるので、ここでも遅延デコードが効きます。


3. サーバー起動とガード順序(handleMCPmcp_server.go:130

3.1 起動・再起動

サーバーの組み立ては素朴です。http.ServeMux に3つのパスを生やすだけ(mcp_server.go:53startMCPServer)。

  • /healthz … ヘルスチェック(接続中エージェント数も返す)
  • /mcp … 本体。handleMCP に丸投げ
  • /audit/events … 監査用の履歴ダンプ

http.Server には ReadHeaderTimeout / ReadTimeout / MaxHeaderBytes を設定しています(mcp_server.go:90)。ローカル待受(既定 127.0.0.1:8787)とはいえ、タイムアウトと上限は最初から付けておきます。

設定変更時は restartMCPServermcp_server.go:113)が2秒で graceful shutdown してから再起動します。設定保存のたびにこれが呼ばれ、ポートやパスの変更が即反映されます。

3.2 ガードの順序とその理由

本体 handleMCPmcp_server.go:130)は、リクエストを処理する前に門を順番にくぐらせます。この順序には理由があります。

1. CORS プリフライト(OPTIONS)への即応
2. エージェント記録(rememberMCPAgent)
3. POST 以外を拒否
4. Bearer トークン検証(有効時のみ)
5. ボディサイズ上限(MaxBytesReader)
6. JSON デコード
7. 通知(notifications/*)は 202 で即返し
8. dispatchMCP へ

順を追って、なぜこの並びなのかを見ます。

(1) OPTIONS を最初に返すmcp_server.go:131)。ブラウザ系クライアントの CORS プリフライトは認証もボディも持たないので、何より先に 204 No Content で返します。ここで Access-Control-Allow-Originhttp://localhost に限定しているのもポイントです。

// mcp_server.go:131
if r.Method == http.MethodOptions {
    w.Header().Set("Access-Control-Allow-Origin", "http://localhost")
    w.Header().Set("Access-Control-Allow-Headers", "content-type, authorization")
    w.WriteHeader(http.StatusNoContent)
    return
}

(2) エージェント記録を認証より前に置くmcp_server.go:137)。rememberMCPAgent(r) は「誰が叩きに来たか」を記録します。これは認証で弾かれる前に呼びます。未承認の接続試行も含めて観測したいからです(GUI の「被接続エージェント数」に効く)。

(3) POST 以外を拒否mcp_server.go:138)。Streamable HTTP の本体は POST です。GET などは -32600 POST only で返します。

(4) Bearer 検証は POST と判定した後mcp_server.go:145)。トークンが有効化されているときだけ、Authorization: Bearer <token> を厳密一致でチェックします。401 -32001 unauthorized

// mcp_server.go:145
if settings.BearerEnabled && (token == "" || r.Header.Get("Authorization") != "Bearer "+token) {
    writeJSON(w, http.StatusUnauthorized, rpcResponse{
        JSONRPC: "2.0",
        Error:   &rpcError{Code: -32001, Message: "unauthorized"},
    })
    return
}

(5) ボディ上限を「認証の後・パースの前」に置くmcp_server.go:153)。これが順序設計の肝です。

// mcp_server.go:153
r.Body = http.MaxBytesReader(w, r.Body, int64(settings.MaxBodyKB)*1024)
defer r.Body.Close()
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    writeJSON(w, http.StatusBadRequest, rpcResponse{
        JSONRPC: "2.0",
        Error:   &rpcError{Code: -32700, Message: "invalid json"},
    })
    return
}

http.MaxBytesReader で包んでから json.NewDecoder(...).Decode を回すので、巨大ボディを全部メモリに読む前に途中で打ち切れます。認証より後に置くのは、未認証の相手のためにわざわざボディを読みに行かないためです。安いチェック(メソッド・トークン)を先に、高くつくチェック(ボディ読み込み・JSON パース)を後に。順序は「コストが低く拒否確度が高いものから」が原則です。

(7) 通知は 202 で握りつぶすmcp_server.go:163)。id が無く notifications/ で始まるメソッドは、JSON-RPC の通知なので応答を返してはいけません。202 Accepted だけ返して終わります。

// mcp_server.go:163
if req.ID == nil && strings.HasPrefix(req.Method, "notifications/") {
    w.WriteHeader(http.StatusAccepted)
    return
}

ここまで通った正規のリクエストだけが dispatchMCP に進み、結果は 200 OKrpcResponse で返ります(mcp_server.go:168)。

なお、JSON-RPC のエラー(method not found 等)でも HTTP ステータスは 200 OK で返し、error フィールドに詰めます。HTTP 層の失敗(POST 以外・未認証・JSON 不正)だけが非 200 です。この線引きは JSON-RPC の慣習に沿っています。


4. メソッドのディスパッチとツール実行

4.1 dispatchMCPmcp_server.go:201

メソッド名で switch するだけです。

// mcp_server.go:201
switch req.Method {
case "initialize":
    // protocolVersion をエコー(無ければ "2025-06-18")、capabilities/serverInfo を返す
case "tools/list":
    return map[string]any{"tools": mcpTools()}, nil
case "tools/call":
    var params toolCallParams
    if err := json.Unmarshal(req.Params, &params); err != nil {
        return nil, &rpcError{Code: -32602, Message: "invalid tool params"}
    }
    return a.callMCPTool(params)
default:
    return nil, &rpcError{Code: -32601, Message: "method not found"}
}

initialize ではクライアントが送ってきた protocolVersion をそのまま返し(無ければ 2025-06-18 を既定に)、capabilities.tools.listChanged: false(ツール一覧は動的に変わらない)と serverInfo(name=ssh-gete, version)を返します。最小限の握手です。

tools/listmcpTools()mcp_server.go:352)が返す静的なツール定義をそのまま渡すだけ。各ツールは name / description / inputSchema(JSON Schema)を持ちます。

4.2 4つのツール(callMCPToolmcp_server.go:232

tools/call の本体です。ツール名で再び switch します。ssh-gete のツールは4つ。

ツール 役割 副作用
list_hosts 登録済み接続先の一覧 なし(読み取り)
get_command_history 承認キュー+履歴の取得 なし(読み取り)
request_command_execution 実行要求を承認キューへ キューに積む
execute_command (名前に反して)承認キューへ キューに積む

list_hostsget_command_historymu.RLock の下で内部状態をコピーして textToolResult で包むだけの読み取り系です。問題は実行系の2つ。

4.3 「execute_command が実行しない」という設計

ツール名は execute_command ですが、MCP 経由ではコマンドを直接実行しませんdescription にもそう書いてあります(mcp_server.go:365)。

execute_command:「コマンド実行要求を承認キューへ登録します。MCP経由ではLowリスクでも直接SSH実行しません。」

request_command_executionexecute_command はほぼ同一の流れで、共通の本体は executeCommandToolmcp_server.go:305)にまとまっています。流れはこうです。

// mcp_server.go:305(executeCommandTool、抜粋)
args.Host = strings.TrimSpace(args.Host)
args.Command = strings.TrimSpace(args.Command)
if args.Host == "" || args.Command == "" {
    return nil, &rpcError{Code: -32602, Message: "host and command are required"}
}
agentName := commandAgentName(args)        // agent_name か requested_by
if agentName == "" {
    return nil, &rpcError{Code: -32602, Message: "agent_name is required"}
}

risk := classifyRisk(args.Command)
conn, ok := a.findConnection(args.Host)
if !ok {
    return nil, &rpcError{Code: -32602, Message: "host not found"}
}
if conn.connectionType() == "Serial" {
    return nil, &rpcError{Code: -32602,
        Message: "serial connections are interactive-only; not executable via MCP"}
}
if a.agentApprovalBypassEnabled(agentName) {
    req := a.executeCommandAndLog(conn, args.Command, args.Reason, agentName, risk)
    return textToolResult(map[string]any{"auto_executed": true, /* stdout/stderr/duration */}), nil
}
req := a.queueCommand(args.Host, args.Command, args.Reason, agentName, risk)
return textToolResult(map[string]any{
    "queued":     true,
    "request_id": req.ID,
    "risk":       risk,
    "reason":     "approval_required",
}), nil

順番に、ガードと分岐を確認します。

  1. host / command 必須。空なら -32602
  2. agent_name 必須(無ければ requested_by を拾う。commandAgentNamemcp_server.go:345)。匿名のエージェントには実行させないための門です。
  3. classifyRisk でリスク判定(後述)。
  4. findConnection で接続先を解決。無ければ host not found
  5. シリアル接続先なら拒否(後述)。
  6. 承認バイパスの有無で分岐
    • agentApprovalBypassEnabled(agentName)true(=人間がそのエージェントを明示的に信頼登録済み)→ executeCommandAndLog即実行し、結果(stdout/stderr/duration)を auto_executed: true で返す。
    • そうでなければ → queueCommand承認キューへ積むだけ。レスポンスは queued: truerequest_idrisk のみ。stdout は返らない

ここが ssh-gete の核です。信頼登録されていないエージェントには、コマンドの出力すら返しません。エージェント側から見えるのは「キューに積みました」という事実だけ。人間が GUI で承認して初めて実行され、結果が履歴に載ります。デフォルトは「実行しない」、例外は「人間が意図的に開けた穴だけ」。

テストでもこの不変条件を固定しています。whoami(Low リスク)ですら承認キュー行きになることを TestMCPExecuteCommandQueuesLowRiskmcp_server_test.go:116)が、sudo systemctl restart nginxHigh承認待ち に積まれることを TestMCPRequestCommandExecutionQueuesmcp_server_test.go:78)が、agent_name 欠落時に agent_name is required を返してキューに積まないことを TestMCPCommandRequiresAgentNamemcp_server_test.go:154)が保証します。

補足:AutoExecuteLowRisk という「Low リスクは自動実行」フラグは構造体に存在しますが、sanitizeMCPSettings常に false に固定され、UI からも有効化できません(解説.md 14.1)。設定のどの経路を通っても最終的に false に倒れます。つまり唯一の自動実行経路は、上の「エージェント単位の明示的な承認バイパス」だけです。


5. list_hoststype を返す — シリアル接続先は MCP から実行拒否

ssh-gete は SSH だけでなくシリアル接続先も管理します(第6回)。しかしシリアルは「コマンド→終了コード」モデルを持たない対話ストリームなので、承認キュー(=コマンド単位の承認)には乗りません。シリアルは GUI の対話端末専用です。

この役割分担を、MCP 側でもコードで強制しています。

まず、エージェントが SSH とシリアルを区別できるよう、list_hosts は各接続先の type を返します。

// mcp_server.go:234(list_hosts)
hosts = append(hosts, map[string]any{
    "name":        conn.Name,
    "type":        conn.connectionType(),   // "SSH" | "Serial"
    "host":        conn.Host,
    "port":        conn.Port,
    "user":        conn.User,
    "auth_method": conn.AuthMethod,
    "tags":        conn.Tags,
    "status":      conn.Status,
})

connectionType()app.go:71)は、種別が "Serial" のときだけ "Serial" を返し、それ以外(種別未設定の旧レコード含む)は "SSH" 扱いにする後方互換ヘルパーです。

そして実行系ツールでは、第4節で見たとおり、解決した接続先がシリアルなら即拒否します(mcp_server.go:271321 の2か所)。

if conn.connectionType() == "Serial" {
    return nil, &rpcError{Code: -32602,
        Message: "serial connections are interactive-only; not executable via MCP"}
}

このガードが無いと、ホストが空のシリアル接続先に対して SSH 実行を試み、無駄な失敗履歴が残るだけでした。TestMCPRejectsSerialConnectionmcp_server_test.go:190)が回帰テストとしてこれを守ります。種別 Serial の接続先に execute_command を投げると、

  • レスポンスの error.Messageserial が含まれること(拒否)
  • 承認キューに1件も積まれないこと(GetInitialData().Requests が 0)

の両方を検証しています。「拒否する」だけでなく「副作用を一切残さない」ことまで確認しているのがポイントです。


6. リスク分類 classifyRisk のヒューリスティックと、その限界

実行系ツールはコマンド文字列を classifyRiskmcp_server.go:427)に通し、High / Medium / Low のラベルを付けます。

// mcp_server.go:427
func classifyRisk(command string) string {
    lower := strings.ToLower(command)
    highPatterns := []string{
        " rm ", "rm -", "rm\t", "mkfs", "dd if=", "shutdown", "reboot", "halt",
        "systemctl restart", "systemctl stop", "service restart", "service stop",
        "iptables", "ufw ", "userdel", "passwd ", "chown -r", "chmod -r",
    }
    for _, pattern := range highPatterns {
        if strings.Contains(" "+lower+" ", pattern) {
            return "High"
        }
    }
    mediumPatterns := []string{
        "sudo ", "apt ", "apt-get ", "yum ", "dnf ", "brew ", "docker rm",
        "docker stop", "kubectl delete", "mv ", "cp -r", "truncate",
    }
    // ... Medium 判定 ...
    return "Low"
}

やっていることは単純で、小文字化して前後に空白を足し、部分文字列マッチするだけです。rm -rfmkfsshutdown は High、sudoaptkubectl delete は Medium、それ以外は Low。

ここで正直に書いておくべき限界があります。これは構文解析ではなく文字列マッチのヒューリスティックなので、簡単に騙せます。

  • r""m -rf /(クオートで分断)、/bin/rm(パターン rm に当たらない)
  • エイリアスや環境変数経由($DESTRUCTalias x=rm
  • base64 デコードしてパイプ、など

だからこそ、ssh-geteclassifyRisk の結果を「実行可否の判断」には使いません。リスクラベルはあくまで**人間が承認画面で判断するときの目安(色分け・優先度)**として表示するだけです。実行可否を決めるのは「人間の承認」と「エージェント単位の明示的なバイパスポリシー」であって、文字列マッチではありません。

これは設計上とても重要な割り切りです。「危険なコマンドを自動で弾く」方向に倒すと、ヒューリスティックの抜け穴がそのままセキュリティホールになります。ssh-gete は逆向きで、何も信用せず全部キューに積み、人間に見せるclassifyRisk が間違えても(High を Low と誤判定しても)、承認の門は閉じたまま——だから安全側に倒れます。信用できない分類器を、信用しない場所に置く設計です。


7. rememberMCPAgent と未申告エージェントの扱い

第3節で触れたとおり、rememberMCPAgentmcp_server.go:177)は認証より前に呼ばれ、接続元を記録します。

// mcp_server.go:177
func (a *App) rememberMCPAgent(r *http.Request) {
    key := r.Header.Get("User-Agent")
    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err == nil && host != "" {
        key = host + " " + key            // IP + User-Agent をキーに
    }
    if strings.TrimSpace(key) == "" {
        key = r.RemoteAddr
    }
    a.mu.Lock()
    if a.mcpAgents == nil {
        a.mcpAgents = map[string]time.Time{}
    }
    a.mcpAgents[key] = time.Now()         // 最終観測時刻を更新
    a.mcp.ConnectedAgents = a.connectedAgentsLocked()
    a.mu.Unlock()
}

mcpAgents は「キー(IP + User-Agent)→ 最終観測時刻」のマップです。connectedAgentsLockedapp.go:485)は直近5分以内に観測されたキーを数え、それが GUI の「被接続エージェント数」になります。HTTP リクエストの観測(接続の事実)と、ツール呼び出し時の agent_name(自己申告のアイデンティティ)は別物である点に注意してください。

ここで「未申告エージェント」という概念が出てきます。実行系ツールは agent_name(または requested_by)を必須にしていますが(commandAgentNamemcp_server.go:345)、これはエージェントの自己申告にすぎません。ssh-gete はこの申告名を「信頼登録(承認バイパス)」のキーとして使います。

つまり扱いは2段構えです。

  • 接続の観測rememberMCPAgent):認証前から無条件に記録。誰が叩きに来たかを可視化する監査目的。
  • アイデンティティの申告agent_name):ツール呼び出しごとに必須。これが無いコマンド要求は弾く(agent_name is required)。そして、人間が GUI で特定の agent_name に承認バイパスを与えたときだけ、そのエージェントは自動実行できる。

匿名(agent_name 空)では、そもそもコマンド要求が -32602 で弾かれます。未申告エージェント のような名前に承認バイパスを与えることはできません(第3回で扱う承認 UI 側でも、未申告には信頼付与不可)。「名乗らないエージェントには何もさせない」——これも安全側の既定です。


まとめ — 難しいのは「ツールに何をさせないか」

この記事で見たことを整理します。

  • MCP サーバーの実体は JSON-RPC 2.0 over Streamable HTTPnet/http + encoding/json だけで、initialize / tools/list / tools/call の3メソッドを書けば成立する。SDK は要らない。
  • 型は ID anyParams/Arguments json.RawMessage遅延デコードが効く。ディスパッチの時点で中を見ない。
  • HTTP ハンドラのガード順序は「コストが低く拒否確度が高いものから」。OPTIONS 即応 → エージェント記録(認証前)→ POST 限定 → Bearer → ボディ上限 → JSON パース → 通知握りつぶし → dispatch。未認証の相手のためにボディを読みに行かない
  • 4ツールのうち実行系(execute_command / request_command_execution)は実行せずキューに積む。出力すら返さない。唯一の自動実行は「人間が明示的に承認バイパスを与えたエージェント」だけ。
  • シリアル接続先は対話専用なので、list_hoststype を見せつつ、実行系ではコードで拒否TestMCPRejectsSerialConnection)。
  • classifyRisk信用できないヒューリスティック。だから実行可否には使わず、人間への表示にとどめる。誤判定しても承認の門は閉じたまま、という安全側設計。

MCP サーバーを動かすだけなら、半日もあれば書けます。本当に時間をかけるべきは、その先——ツールに何をさせて、何をさせないかの境界設計です。ssh-gete の場合、それは「デフォルトで実行しない」「匿名には何もさせない」「危険判定を信用しない」という3つの『させない』に凝縮されています。

次回(第3回)は、承認ボタンが押された瞬間から SSH 実行・監査ログまでの心臓部を追います。承認キューに積まれたコマンドが、どう実機に届くのか。


参照コード(本記事の引用元)

  • mcp_server.go:15 — JSON-RPC 型定義(rpcRequest / rpcResponse / rpcError
  • mcp_server.go:38 / mcp_server.go:43toolCallParams / executeCommandArgsjson.RawMessage 遅延デコード)
  • mcp_server.go:53 / mcp_server.go:113startMCPServer / restartMCPServer
  • mcp_server.go:130handleMCP(ガード順序)
  • mcp_server.go:177rememberMCPAgent(接続観測・認証前)
  • mcp_server.go:201dispatchMCP(メソッド switch
  • mcp_server.go:232callMCPTool(4ツールの分岐、list_hoststype、シリアル拒否)
  • mcp_server.go:305executeCommandTool(必須引数・シリアル拒否・承認バイパス分岐)
  • mcp_server.go:345commandAgentNameagent_name / requested_by
  • mcp_server.go:352mcpTools(ツール定義 / JSON Schema)
  • mcp_server.go:427classifyRisk(リスク分類ヒューリスティック)
  • app.go:71connectionType()(後方互換)
  • app.go:485 / app.go:627connectedAgentsLocked / agentApprovalBypassEnabled
  • app.go:638 / app.go:668queueCommand / executeCommandAndLog
  • mcp_server_test.go:78 / 116 / 154 / 190 — キュー投入・Low リスクもキュー・agent_name 必須・TestMCPRejectsSerialConnection

逐行の詳細は docs/解説.md 第7章「MCP サーバー mcp_server.go の詳説」、付録B「逐行コードリーディング」、第11章「テスト」を参照。

この記事はオープンソース ssh-gate の紹介記事です。


関連記事


図1.png

クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?