GoでstructからJSON Schemaを生成してバリデーションする方法

# 背景
MCPサーバのToolが使用される際に、MCPクライアントから送られてくるパラメータについてはLLMが何を考えているのかわからないように、何が送られてくるかはわかりません。
しかし流石にデタラメなものが送られてきては困るので、Toolには`inputShema`というJSON Schemaを返すレスポンスがあり、そこでパラメータの定義を行っています。

https://modelcontextprotocol.io/docs/concepts/tools#tool-definition-structure

ただしあくまでMCPクライアントにパラメーターの定義を送っているのみで、現状MCPサーバのSDKで`inputSchema`のJSON Schemaを元にバリデーションしていないものが多そうです。
また人間が不正なパラメータを送るように指示してしまえば、MCPクライアントは人間の言う事に従って不正なパラメータを送ってきます。

そのため、安全にするためにはToolの実装側でJSON Schemaを元にバリデーションを行う必要があります。
単純なバリデーションであればよいですが、[If-Then-Else](https://modelcontextprotocol.io/docs/concepts/tools#tool-definition-structure)などを使用したバリデーションをJSON Schemaで定義をしているのに、別でバリデーション処理を実装するのは少し面倒です。

そこで2つのOSSを使用させていただいて、GoのstructからJSON Schemaを生成してバリデーションを行う方法を実際に試してうまく行ったので紹介します。

# structからJSON Schemaを生成する方法

今回使用したライブラリは以下になります。

https://github.com/swaggest/jsonschema-go

このライブラリではstructのフィールドにタグを付与することで、JSON Schemaを生成することができます。

## structでのJSON Schema定義方法
例えば、`enum`や`required`などのタグは下記のように付与することができます。

```go
type BotDeviceCommandParameter struct {
	Command string   `json:"command" title:"Command" enum:"TurnOn,TurnOff,Press" description:"TurnOn:set to OFF state, TurnOff:set to ON state, Press:trigger press" required:"true"`
	_       struct{} `additionalProperties:"false"`
}
```

また[If-Then-Else](https://modelcontextprotocol.io/docs/concepts/tools#tool-definition-structure)のような条件を定義したい場合は、下記のようにstructに`JSONSchemaIf`や`JSONSchemaThen`などのメソッドを実装することで、表現することができます。

```go
type CurtainDeviceCommandParameter struct {
	Command  string   `json:"command" title:"Command" enum:"TurnOn,TurnOff,Pause,SetPosition" description:"TurnOn:equivalent to set position to 100, TurnOff:equivalent to set position to 0, Pause:set to PAUSE state, SetPosition:set position" required:"true"`
	Mode     string   `json:"mode" title:"Mode" enum:"0,1,ff" description:"0:performance mode, 1:silent mode, ff:default mode"`
	Position int      `json:"position" title:"Position" minimum:"0" maximum:"100"`
	_        struct{} `additionalProperties:"false"`
}

func (parameter *CurtainDeviceCommandParameter) JSONSchemaIf() interface{} {
	return struct {
		Command string `json:"command" const:"SetPosition" required:"true"`
	}{}
}

func (parameter *CurtainDeviceCommandParameter) JSONSchemaThen() interface{} {
	return struct {
		Mode     string `json:"mode" required:"true"`
		Position int    `json:"position" required:"true"`
	}{}
}
```

更に複数の条件があり、`allOf`を使用したい場合は`JSONSchemaAllOf`メソッドを実装して下記のように表現ができます。

```go
type CeilingLightDeviceCommandParameter struct {
	Command          string   `json:"command" title:"Command" enum:"TurnOn,TurnOff,Toggle,SetBrightness,SetColorTemperature" description:"TurnOn:turn on the ceiling light, TurnOff:turn off the ceiling light, Toggle:toggle the ceiling light, SetBrightness:set brightness, SetColorTemperature:set color temperature" required:"true"`
	Brightness       int      `json:"brightness" title:"Brightness" minimum:"1" maximum:"100" description:"Brightness level (1-100)"`
	ColorTemperature int      `json:"colorTemperature" title:"ColorTemperature" minimum:"2700" maximum:"6500" description:"Color temperature in Kelvin (2700-6500)"`
	_                struct{} `additionalProperties:"false"`
}

type CeilingLightDeviceCommandSetBrightnessIfExposer struct{}

func (parameter *CeilingLightDeviceCommandSetBrightnessIfExposer) JSONSchemaIf() interface{} {
	return struct {
		Command string `json:"command" const:"SetBrightness" required:"true"`
	}{}
}

func (parameter *CeilingLightDeviceCommandSetBrightnessIfExposer) JSONSchemaThen() interface{} {
	return struct {
		Brightness int `json:"brightness" required:"true"`
	}{}
}

type CeilingLightDeviceCommandSetColorTemperatureIfExposer struct{}

func (parameter *CeilingLightDeviceCommandSetColorTemperatureIfExposer) JSONSchemaIf() interface{} {
	return struct {
		Command string `json:"command" const:"SetColorTemperature" required:"true"`
	}{}
}

func (parameter *CeilingLightDeviceCommandSetColorTemperatureIfExposer) JSONSchemaThen() interface{} {
	return struct {
		ColorTemperature int `json:"colorTemperature" required:"true"`
	}{}
}

func (parameter *CeilingLightDeviceCommandParameter) JSONSchemaAllOf() []interface{} {
	return []interface{}{
		&CeilingLightDeviceCommandSetBrightnessIfExposer{},
		&CeilingLightDeviceCommandSetColorTemperatureIfExposer{},
	}
}
```

## JSON Schemaの文字列での取得方法

JSON Schemaの定義をしたstructを文字列に変換する場合は下記のようなコードで文字列に変換することができます。

```go
reflector := jsonschema.Reflector{}
// MEMO: $refを使用させないためにInlineRefsを指定しています
schema, err := reflector.Reflect(CeilingLightDeviceCommandParameter{}, jsonschema.InlineRefs)
if err != nil {
  return "", err
}
jsonString, err := json.Marshal(schema)
if err != nil {
  return "", err
}
return string(jsonString), nil
```

# JSON Schemaを元にバリデーションする方法

今回使用したライブラリは以下になります。

https://github.com/kaptinlin/jsonschema

このライブラリでは、下記のようにJSON Schemaを元にバリデーションを行うことができます。

```go
func validateAndUnmarshalJSON(device ExecutableCommandDevice, jsonString string, target interface{}) error {
	schemaJSON, err := device.GetCommandParameterJSONSchema()
	if err != nil {
		return err
	}

	compiler := jsonschemaValidation.NewCompiler()
	schema, err := compiler.Compile([]byte(schemaJSON))
	if err != nil {
		return err
	}

	var instance map[string]interface{}
	err = json.Unmarshal([]byte(jsonString), &instance)
	if err != nil {
		return err
	}

	result := schema.Validate(instance)
	if !result.IsValid() {
		errorDetails, _ := json.Marshal(result.ToList())
		return fmt.Errorf("invalid command parameter: %s", string(errorDetails))
	}

	return json.Unmarshal([]byte(jsonString), target)
}
```

structからJSON Schemaを生成するためのライブラリと名前が被っているため、`jsonschemaValidation "github.com/kaptinlin/jsonschema"`と別名でインポートしているのに注意してください。
`GetCommandParameterJSONSchema`メソッドでJSON Schemaを文字列で取得しています。
また、検証対象のJSONは`jsonString`で受け取っています。

# 最後に
これら2つのライブラリを使用することでGoのstructでJSON Schemaを定義して、更にそれを使いまわしてバリデーションするということができるようになりました。

ただしSDKの実装の都合などもあり、実際にはMCPサーバの`inputShema`のレスポンスに直接今回の仕組みを使っているわけではなく、パラメータとして文字列を受け取るようにしてそこで今回の仕組みを使っています。
今回のサンプルコードは[SwitchBotのMCPサーバ](https://github.com/yasu89/switch-bot-mcp-server)で使用しているAPIライブラリのものになるため、もう少し具体的な実装例を見たい方は下記のコードを参考にしてみてください。

https://github.com/yasu89/switch-bot-api-go/blob/278c57e45f22a0d84b246b40a223137a14f5b83a/device_control_json.go