From 62d3c1c12c05aab977bdc935d6b6c2a15f51c33e Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Tue, 14 Apr 2026 23:55:05 +0300 Subject: [PATCH 01/74] test(01-01): add JVM language option parser coverage --- internal/codegen/options_test.go | 65 ++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/internal/codegen/options_test.go b/internal/codegen/options_test.go index 2f2ceb3..aa6d25f 100644 --- a/internal/codegen/options_test.go +++ b/internal/codegen/options_test.go @@ -16,6 +16,41 @@ func TestParseOptions_DefaultLangGo(t *testing.T) { } } +func TestParseOptions_JVMLanguages(t *testing.T) { + tests := []struct { + name string + raw string + want Language + }{ + { + name: "kotlin", + raw: "lang=kotlin", + want: LanguageKotlin, + }, + { + name: "java", + raw: "lang=java", + want: LanguageJava, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts, err := ParseOptions(tt.raw) + if err != nil { + t.Fatalf("ParseOptions returned error: %v", err) + } + + if opts.Language != tt.want { + t.Fatalf("Language = %q, want %q", opts.Language, tt.want) + } + if opts.PythonRuntime != "" { + t.Fatalf("PythonRuntime = %q, want empty", opts.PythonRuntime) + } + }) + } +} + func TestParseOptions_PythonDefaultsRuntime(t *testing.T) { opts, err := ParseOptions("lang=python") if err != nil { @@ -56,10 +91,32 @@ func TestParseOptions_PythonRuntimeConstants(t *testing.T) { } } -func TestParseOptions_PythonRuntimeRejectedForGo(t *testing.T) { - _, err := ParseOptions("lang=go,python_runtime=google.protobuf") - if err == nil { - t.Fatal("ParseOptions succeeded, want error") +func TestParseOptions_PythonRuntimeRejectedForNonPythonLanguages(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "go", + raw: "lang=go,python_runtime=google.protobuf", + }, + { + name: "kotlin", + raw: "lang=kotlin,python_runtime=google.protobuf", + }, + { + name: "java", + raw: "lang=java,python_runtime=google.protobuf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseOptions(tt.raw) + if err == nil { + t.Fatal("ParseOptions succeeded, want error") + } + }) } } From cab3b3ecfd9aea3d859a345e53086bc440f3547a Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Tue, 14 Apr 2026 23:55:27 +0300 Subject: [PATCH 02/74] feat(01-01): accept JVM generator language options --- internal/codegen/options.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/codegen/options.go b/internal/codegen/options.go index 5670cf7..1b6da9c 100644 --- a/internal/codegen/options.go +++ b/internal/codegen/options.go @@ -10,6 +10,8 @@ type Language string const ( LanguageGo Language = "go" LanguagePython Language = "python" + LanguageKotlin Language = "kotlin" + LanguageJava Language = "java" ) type PythonRuntime string @@ -62,6 +64,10 @@ func (p *OptionsParser) Options() (Options, error) { if p.sawPythonRuntime { return Options{}, fmt.Errorf("python_runtime is only supported when lang=python") } + case LanguageKotlin, LanguageJava: + if p.sawPythonRuntime { + return Options{}, fmt.Errorf("python_runtime is only supported when lang=python") + } case LanguagePython: if !p.sawPythonRuntime { p.opts.PythonRuntime = PythonRuntimeGoogleProtobuf From fee6428ae7adbdd3fd2f78d3f35d556aff0d84f2 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Tue, 14 Apr 2026 23:57:57 +0300 Subject: [PATCH 03/74] feat(01-02): add shared JVM naming helpers --- internal/codegen/jvm_names.go | 91 ++++++++++++++++++++++++++++++ internal/codegen/jvm_names_test.go | 57 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 internal/codegen/jvm_names.go create mode 100644 internal/codegen/jvm_names_test.go diff --git a/internal/codegen/jvm_names.go b/internal/codegen/jvm_names.go new file mode 100644 index 0000000..35bf426 --- /dev/null +++ b/internal/codegen/jvm_names.go @@ -0,0 +1,91 @@ +package codegen + +import ( + "fmt" + "path" + "strings" + "unicode" + + "google.golang.org/protobuf/compiler/protogen" +) + +func jvmGeneratedFilenamePrefixForProtoPath(protoPath string) string { + prefix := strings.TrimSuffix(protoPath, path.Ext(protoPath)) + parts := strings.Split(prefix, "/") + for i, part := range parts { + parts[i] = strings.ReplaceAll(part, "-", "_") + } + return strings.Join(parts, "/") +} + +func jvmPublicTypeName(message *protogen.Message) string { + return jvmPublicIdentifier(descriptorTypePath(message.Desc.FullName(), message.Desc.ParentFile().Package())) +} + +func jvmPublicEnumName(enum *protogen.Enum) string { + return jvmPublicIdentifier(descriptorTypePath(enum.Desc.FullName(), enum.Desc.ParentFile().Package())) +} + +func jvmPublicIdentifier(parts []string) string { + var b strings.Builder + for _, part := range parts { + b.WriteString(jvmExportedIdentifier(part)) + } + return b.String() +} + +func jvmExportedIdentifier(value string) string { + return pythonExportedIdentifier(value) +} + +func jvmLowerCamelIdentifier(value string) string { + exported := jvmExportedIdentifier(value) + if exported == "" { + return "" + } + + runes := []rune(exported) + runes[0] = unicode.ToLower(runes[0]) + return string(runes) +} + +func jvmMethodName(method string) string { + return jvmLowerCamelIdentifier(method) +} + +func jvmHandlerName(service string) string { + return jvmExportedIdentifier(service) + "ToolHandler" +} + +func jvmRegisterName(service string) string { + return "register" + jvmExportedIdentifier(service) + "Tools" +} + +func jvmSchemaConst(service, method string) string { + return toUpperSnakeCase(service) + "_" + toUpperSnakeCase(method) +} + +func jvmOneofWrapperName(typeName, oneofName string) string { + return typeName + jvmExportedIdentifier(oneofName) + "Variant" +} + +func jvmOneofVariantWrapperName(typeName, oneofName, fieldName string) string { + return typeName + jvmExportedIdentifier(oneofName) + jvmExportedIdentifier(fieldName) + "Variant" +} + +func jvmCheckNameCollision(registry map[string]string, category, name, owner string) error { + if existing, ok := registry[name]; ok && existing != owner { + return fmt.Errorf("jvm %s collision for %q between %s and %s", category, name, existing, owner) + } + registry[name] = owner + return nil +} + +func jvmCheckOwnedNameCollision(scoped map[string]map[string]string, scope, category, name, owner string) error { + registry := scoped[scope] + if registry == nil { + registry = map[string]string{} + scoped[scope] = registry + } + return jvmCheckNameCollision(registry, category, name, owner) +} diff --git a/internal/codegen/jvm_names_test.go b/internal/codegen/jvm_names_test.go new file mode 100644 index 0000000..9e94161 --- /dev/null +++ b/internal/codegen/jvm_names_test.go @@ -0,0 +1,57 @@ +package codegen + +import ( + "strings" + "testing" +) + +func TestJVMNames_GeneratedAPINames(t *testing.T) { + if got, want := jvmMethodName("CreateReport"), "createReport"; got != want { + t.Fatalf("jvmMethodName = %q, want %q", got, want) + } + if got, want := jvmHandlerName("ExampleAPI"), "ExampleAPIToolHandler"; got != want { + t.Fatalf("jvmHandlerName = %q, want %q", got, want) + } + if got, want := jvmRegisterName("ExampleAPI"), "registerExampleAPITools"; got != want { + t.Fatalf("jvmRegisterName = %q, want %q", got, want) + } + if got, want := jvmSchemaConst("ExampleAPI", "CreateReport"), "EXAMPLE_API_CREATE_REPORT"; got != want { + t.Fatalf("jvmSchemaConst = %q, want %q", got, want) + } + if got, want := jvmOneofWrapperName("CreateReportRequest", "city"), "CreateReportRequestCityVariant"; got != want { + t.Fatalf("jvmOneofWrapperName = %q, want %q", got, want) + } + if got, want := jvmOneofVariantWrapperName("CreateReportRequest", "city", "city_id"), "CreateReportRequestCityCityIdVariant"; got != want { + t.Fatalf("jvmOneofVariantWrapperName = %q, want %q", got, want) + } +} + +func TestJVMNames_NormalizesProtoPath(t *testing.T) { + if got, want := jvmGeneratedFilenamePrefixForProtoPath("test/v1/my-file.proto"), "test/v1/my_file"; got != want { + t.Fatalf("jvmGeneratedFilenamePrefixForProtoPath = %q, want %q", got, want) + } +} + +func TestJVMNames_FailsOnCollision(t *testing.T) { + err := jvmCheckNameCollision(map[string]string{ + "Report": "test.v1.Report", + }, "public type name", "Report", "other.v1.Report") + if err == nil { + t.Fatal("jvmCheckNameCollision unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "jvm") { + t.Fatalf("collision error = %q, want jvm diagnostic", err) + } + + scoped := map[string]map[string]string{} + if err := jvmCheckOwnedNameCollision(scoped, "test/v1/report.proto", "public type name", "Report", "test.v1.Report"); err != nil { + t.Fatalf("jvmCheckOwnedNameCollision first insert: %v", err) + } + err = jvmCheckOwnedNameCollision(scoped, "test/v1/report.proto", "public type name", "Report", "test.v1.OtherReport") + if err == nil { + t.Fatal("jvmCheckOwnedNameCollision unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "jvm") { + t.Fatalf("owned collision error = %q, want jvm diagnostic", err) + } +} From b4ea98dca09ed2904e304b48c946b009f1633ac3 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Tue, 14 Apr 2026 23:59:05 +0300 Subject: [PATCH 04/74] feat(01-02): define shared JVM semantic model --- internal/codegen/jvm_model.go | 167 +++++++++++++++++++++++++++++ internal/codegen/jvm_model_test.go | 65 +++++++++++ 2 files changed, 232 insertions(+) create mode 100644 internal/codegen/jvm_model.go create mode 100644 internal/codegen/jvm_model_test.go diff --git a/internal/codegen/jvm_model.go b/internal/codegen/jvm_model.go new file mode 100644 index 0000000..184c833 --- /dev/null +++ b/internal/codegen/jvm_model.go @@ -0,0 +1,167 @@ +package codegen + +import ( + mcpoptionsv1 "github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1" +) + +type JVMFileModel struct { + Language Language + ProtoPath string + GeneratedFilenamePrefix string + ProtoPackage string + Services []JVMServiceModel + Types JVMTypeGraph +} + +type JVMServiceModel struct { + ProtoFullName string + ProtoName string + Namespace string + Description string + HandlerName string + RegisterName string + Icons []*mcpoptionsv1.Icon + Methods []JVMMethodModel +} + +type JVMMethodModel struct { + ProtoFullName string + ProtoName string + ToolName string + Title string + Description string + Examples []string + Deprecated bool + MethodName string + SchemaConst string + Input JVMTypeRef + Output JVMTypeRef + InputSchemaJSON string + OutputSchemaJSON string + Annotations *mcpoptionsv1.ToolAnnotations + Icons []*mcpoptionsv1.Icon +} + +type JVMTypeGraph struct { + CurrentFile JVMTypeOwner + Imports []JVMImport + Types []JVMType +} + +type JVMImport struct { + ProtoPath string + GeneratedFilenamePrefix string + ProtoPackage string +} + +type JVMTypeOwner struct { + ProtoPath string + IsCurrentFile bool + GeneratedFilenamePrefix string + ProtoPackage string +} + +type JVMTypeKind string + +const ( + JVMTypeKindMessage JVMTypeKind = "message" + JVMTypeKindEnum JVMTypeKind = "enum" +) + +type JVMType struct { + Kind JVMTypeKind + ProtoFullName string + ProtoName string + PublicName string + Owner JVMTypeOwner + NestingPath []string + Fields []JVMField + Oneofs []JVMOneof + EnumValues []JVMEnumValue + WellKnownType JVMWellKnownType +} + +type JVMEnumValue struct { + ProtoName string + Number int32 + Hidden bool +} + +type JVMField struct { + ProtoName string + JSONName string + Number int + Type JVMTypeRef + IsRepeated bool + IsMap bool + MapKeyScalar JVMScalar + MapValue *JVMTypeRef + HasPresence bool + IsSchemaRequired bool + OneofProtoName string + OneofWrapperName string + VariantWrapperName string +} + +type JVMOneof struct { + ProtoName string + WrapperName string + Variants []JVMOneofVariant +} + +type JVMOneofVariant struct { + ProtoName string + FieldNumber int + WrapperName string + Type JVMTypeRef + HasPresence bool +} + +type JVMTypeRef struct { + ProtoFullName string + ProtoName string + PublicName string + Owner JVMTypeOwner + Scalar JVMScalar + WellKnownType JVMWellKnownType + IsEnum bool + IsMessage bool +} + +type JVMScalar string + +const ( + JVMScalarUnknown JVMScalar = "" + JVMScalarBool JVMScalar = "bool" + JVMScalarBytes JVMScalar = "bytes" + JVMScalarString JVMScalar = "string" + JVMScalarInt32 JVMScalar = "int32" + JVMScalarUInt32 JVMScalar = "uint32" + JVMScalarInt64 JVMScalar = "int64" + JVMScalarUInt64 JVMScalar = "uint64" + JVMScalarFloat JVMScalar = "float" + JVMScalarDouble JVMScalar = "double" +) + +type JVMWellKnownType string + +const ( + JVMWellKnownTypeNone JVMWellKnownType = "" + JVMWellKnownTypeAny JVMWellKnownType = "Any" + JVMWellKnownTypeEmpty JVMWellKnownType = "Empty" + JVMWellKnownTypeTimestamp JVMWellKnownType = "Timestamp" + JVMWellKnownTypeDuration JVMWellKnownType = "Duration" + JVMWellKnownTypeFieldMask JVMWellKnownType = "FieldMask" + JVMWellKnownTypeStruct JVMWellKnownType = "Struct" + JVMWellKnownTypeValue JVMWellKnownType = "Value" + JVMWellKnownTypeListValue JVMWellKnownType = "ListValue" + JVMWellKnownTypeBoolValue JVMWellKnownType = "BoolValue" + JVMWellKnownTypeStringValue JVMWellKnownType = "StringValue" + JVMWellKnownTypeBytesValue JVMWellKnownType = "BytesValue" + JVMWellKnownTypeInt32Value JVMWellKnownType = "Int32Value" + JVMWellKnownTypeUInt32Value JVMWellKnownType = "UInt32Value" + JVMWellKnownTypeInt64Value JVMWellKnownType = "Int64Value" + JVMWellKnownTypeUInt64Value JVMWellKnownType = "UInt64Value" + JVMWellKnownTypeFloatValue JVMWellKnownType = "FloatValue" + JVMWellKnownTypeDoubleValue JVMWellKnownType = "DoubleValue" +) diff --git a/internal/codegen/jvm_model_test.go b/internal/codegen/jvm_model_test.go new file mode 100644 index 0000000..13686d3 --- /dev/null +++ b/internal/codegen/jvm_model_test.go @@ -0,0 +1,65 @@ +package codegen + +import ( + "reflect" + "strings" + "testing" +) + +func TestJVMModel_StructuralIRDoesNotContainRawProtogenDescriptors(t *testing.T) { + disallowed := map[string]struct{}{ + "google.golang.org/protobuf/compiler/protogen.Enum": {}, + "google.golang.org/protobuf/compiler/protogen.Field": {}, + "google.golang.org/protobuf/compiler/protogen.File": {}, + "google.golang.org/protobuf/compiler/protogen.Message": {}, + "google.golang.org/protobuf/compiler/protogen.Method": {}, + "google.golang.org/protobuf/compiler/protogen.Service": {}, + } + + assertIRTypeHasNoDisallowedFields(t, reflect.TypeOf(JVMFileModel{}), disallowed) +} + +func TestJVMModel_StructuralIRDoesNotContainSDKTypes(t *testing.T) { + assertIRTypeHasNoPackageSubstring(t, reflect.TypeOf(JVMFileModel{}), "modelcontextprotocol") +} + +func assertIRTypeHasNoPackageSubstring(t *testing.T, typ reflect.Type, disallowed string) { + t.Helper() + + visited := map[reflect.Type]bool{} + var walk func(reflect.Type) + walk = func(current reflect.Type) { + for current.Kind() == reflect.Pointer || current.Kind() == reflect.Slice || current.Kind() == reflect.Array { + current = current.Elem() + } + switch current.Kind() { + case reflect.Map: + walk(current.Key()) + walk(current.Elem()) + return + case reflect.Struct: + default: + return + } + + if visited[current] { + return + } + visited[current] = true + + for i := 0; i < current.NumField(); i++ { + field := current.Field(i) + fieldType := field.Type + baseType := fieldType + for baseType.Kind() == reflect.Pointer || baseType.Kind() == reflect.Slice || baseType.Kind() == reflect.Array { + baseType = baseType.Elem() + } + if strings.Contains(baseType.PkgPath(), disallowed) { + t.Fatalf("%s.%s uses disallowed package %q in field type %s", current.Name(), field.Name, disallowed, fieldType) + } + walk(fieldType) + } + } + + walk(typ) +} From a8eb06937229fce09ed352c4ad656d0ba5d7e723 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 00:03:38 +0300 Subject: [PATCH 05/74] feat(01-02): collect shared JVM model semantics --- internal/codegen/jvm_collect.go | 547 +++++++++++++++++++++++++++++ internal/codegen/jvm_model_test.go | 323 +++++++++++++++++ 2 files changed, 870 insertions(+) create mode 100644 internal/codegen/jvm_collect.go diff --git a/internal/codegen/jvm_collect.go b/internal/codegen/jvm_collect.go new file mode 100644 index 0000000..bfab626 --- /dev/null +++ b/internal/codegen/jvm_collect.go @@ -0,0 +1,547 @@ +package codegen + +import ( + "fmt" + "strings" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func CollectJVMFileModel(file *protogen.File, model FileModel) (JVMFileModel, error) { + switch model.Options.Language { + case LanguageKotlin, LanguageJava: + default: + return JVMFileModel{}, fmt.Errorf("jvm model requires lang=kotlin or lang=java, got %q", model.Options.Language) + } + + methods, err := collectJVMGraphMethods(file) + if err != nil { + return JVMFileModel{}, err + } + methodByFullName := make(map[string]*protogen.Method, len(methods)) + for _, method := range methods { + methodByFullName[string(method.Desc.FullName())] = method + } + + includeCurrentFileTypes := len(methods) == 0 + typeGraph, err := collectJVMTypeGraphFromMethods(file, methods, includeCurrentFileTypes) + if err != nil { + return JVMFileModel{}, err + } + + jvmModel := JVMFileModel{ + Language: model.Options.Language, + ProtoPath: model.ProtoPath, + GeneratedFilenamePrefix: model.GeneratedFilenamePrefix, + ProtoPackage: string(file.Desc.Package()), + Services: make([]JVMServiceModel, 0, len(model.Services)), + Types: typeGraph, + } + + for _, service := range model.Services { + serviceModel := JVMServiceModel{ + ProtoFullName: service.ProtoFullName, + ProtoName: service.ProtoName, + Namespace: service.Namespace, + Description: service.Description, + HandlerName: jvmHandlerName(service.ProtoName), + RegisterName: jvmRegisterName(service.ProtoName), + Icons: service.Icons, + Methods: make([]JVMMethodModel, 0, len(service.Methods)), + } + for _, method := range service.Methods { + descriptorMethod := methodByFullName[method.ProtoFullName] + serviceModel.Methods = append(serviceModel.Methods, newJVMMethodModel(service.ProtoName, method, descriptorMethod, file.Desc.Path())) + } + jvmModel.Services = append(jvmModel.Services, serviceModel) + } + + return jvmModel, nil +} + +func newJVMMethodModel(serviceName string, method MethodModel, descriptorMethod *protogen.Method, currentProtoPath string) JVMMethodModel { + methodModel := JVMMethodModel{ + ProtoFullName: method.ProtoFullName, + ProtoName: method.ProtoName, + ToolName: method.Name, + Title: method.Title, + Description: method.Description, + Examples: append([]string(nil), method.Examples...), + Deprecated: method.Deprecated, + MethodName: jvmMethodName(method.ProtoName), + SchemaConst: jvmSchemaConst(serviceName, method.ProtoName), + Input: newJVMTypeRefFromShared(method.Input), + Output: newJVMTypeRefFromShared(method.Output), + InputSchemaJSON: method.InputSchemaJSON, + OutputSchemaJSON: method.OutputSchemaJSON, + Annotations: method.Annotations, + Icons: method.Icons, + } + if descriptorMethod != nil { + methodModel.Input = newJVMTypeRefFromMessage(descriptorMethod.Input, currentProtoPath) + methodModel.Output = newJVMTypeRefFromMessage(descriptorMethod.Output, currentProtoPath) + } + return methodModel +} + +func newJVMTypeRefFromShared(ref TypeRef) JVMTypeRef { + return JVMTypeRef{ + ProtoFullName: ref.ProtoFullName, + ProtoName: ref.ProtoDisplayName, + PublicName: jvmExportedIdentifier(ref.ProtoDisplayName), + IsMessage: ref.ProtoFullName != "", + } +} + +func newJVMTypeRefFromMessage(message *protogen.Message, currentProtoPath string) JVMTypeRef { + if message == nil { + return JVMTypeRef{} + } + owner := newJVMTypeOwner( + message.Desc.ParentFile().Path(), + string(message.Desc.ParentFile().Package()), + currentProtoPath, + ) + return JVMTypeRef{ + ProtoFullName: string(message.Desc.FullName()), + ProtoName: string(message.Desc.Name()), + PublicName: jvmPublicTypeName(message), + Owner: owner, + IsMessage: true, + } +} + +func collectJVMGraphMethods(file *protogen.File) ([]*protogen.Method, error) { + var methods []*protogen.Method + for _, service := range file.Services { + for _, method := range service.Methods { + _, include, err := selectToolMethod(method) + if err != nil { + return nil, err + } + if !include { + continue + } + methods = append(methods, method) + } + } + return methods, nil +} + +func collectJVMTypeGraphFromMethods(file *protogen.File, methods []*protogen.Method, includeCurrentFileTypes bool) (JVMTypeGraph, error) { + collector := jvmTypeCollector{ + rootProtoPath: file.Desc.Path(), + graph: JVMTypeGraph{ + CurrentFile: newJVMTypeOwner(file.Desc.Path(), string(file.Desc.Package()), file.Desc.Path()), + }, + ownedPublicNames: map[string]map[string]string{}, + seenMessages: map[protoreflect.FullName]bool{}, + seenEnums: map[protoreflect.FullName]bool{}, + } + + for _, method := range methods { + if err := collector.collectMessage(method.Input); err != nil { + return JVMTypeGraph{}, err + } + if err := collector.collectMessage(method.Output); err != nil { + return JVMTypeGraph{}, err + } + } + if includeCurrentFileTypes { + if err := collector.collectCurrentFileTypes(file); err != nil { + return JVMTypeGraph{}, err + } + } + + return collector.graph, nil +} + +type jvmTypeCollector struct { + rootProtoPath string + graph JVMTypeGraph + ownedPublicNames map[string]map[string]string + seenMessages map[protoreflect.FullName]bool + seenEnums map[protoreflect.FullName]bool +} + +func (c *jvmTypeCollector) collectCurrentFileTypes(file *protogen.File) error { + for _, message := range file.Messages { + if err := c.collectMessageTree(message); err != nil { + return err + } + } + for _, enum := range file.Enums { + if err := c.collectEnum(enum); err != nil { + return err + } + } + return nil +} + +func (c *jvmTypeCollector) collectMessageTree(message *protogen.Message) error { + if err := c.collectMessage(message); err != nil { + return err + } + for _, nested := range message.Messages { + if err := c.collectMessageTree(nested); err != nil { + return err + } + } + for _, enum := range message.Enums { + if err := c.collectEnum(enum); err != nil { + return err + } + } + return nil +} + +func (c *jvmTypeCollector) collectMessage(message *protogen.Message) error { + if message == nil || message.Desc.IsMapEntry() { + return nil + } + + if wkt, ok, err := classifyJVMWellKnownType(message.Desc.FullName()); ok { + if err != nil { + return err + } + if wkt != JVMWellKnownTypeNone { + return nil + } + } + + if c.seenMessages[message.Desc.FullName()] { + return nil + } + c.seenMessages[message.Desc.FullName()] = true + + owner, err := c.ownerForFile(message.Desc.ParentFile()) + if err != nil { + return err + } + + publicName := jvmPublicTypeName(message) + ownerKey := string(message.Desc.FullName()) + if err := c.checkOwnedPublicName(owner, "public type name", publicName, ownerKey); err != nil { + return err + } + + model := JVMType{ + Kind: JVMTypeKindMessage, + ProtoFullName: string(message.Desc.FullName()), + ProtoName: string(message.Desc.Name()), + PublicName: publicName, + Owner: owner, + NestingPath: descriptorTypePath(message.Desc.FullName(), message.Desc.ParentFile().Package()), + } + + oneofWrappers := make(map[protoreflect.Name]string, len(message.Oneofs)) + for _, oneof := range message.Oneofs { + if oneof.Desc.IsSynthetic() { + continue + } + wrapperName := jvmOneofWrapperName(publicName, string(oneof.Desc.Name())) + if err := c.checkOwnedPublicName(owner, "oneof wrapper name", wrapperName, ownerKey+"."+string(oneof.Desc.Name())); err != nil { + return err + } + oneofWrappers[oneof.Desc.Name()] = wrapperName + } + + for _, field := range message.Fields { + fieldModel, err := c.collectField(field, oneofWrappers, publicName) + if err != nil { + return err + } + model.Fields = append(model.Fields, fieldModel) + } + + for _, oneof := range message.Oneofs { + if oneof.Desc.IsSynthetic() { + continue + } + wrapperName := oneofWrappers[oneof.Desc.Name()] + oneofModel := JVMOneof{ + ProtoName: string(oneof.Desc.Name()), + WrapperName: wrapperName, + Variants: make([]JVMOneofVariant, 0, len(oneof.Fields)), + } + for _, field := range oneof.Fields { + ref, err := c.typeRefForField(field) + if err != nil { + return err + } + variantWrapper := jvmOneofVariantWrapperName(publicName, string(oneof.Desc.Name()), string(field.Desc.Name())) + if err := c.checkOwnedPublicName(owner, "oneof wrapper name", variantWrapper, ownerKey+"."+string(oneof.Desc.Name())+"."+string(field.Desc.Name())); err != nil { + return err + } + oneofModel.Variants = append(oneofModel.Variants, JVMOneofVariant{ + ProtoName: string(field.Desc.Name()), + FieldNumber: int(field.Desc.Number()), + WrapperName: variantWrapper, + Type: ref, + HasPresence: field.Desc.HasPresence(), + }) + } + model.Oneofs = append(model.Oneofs, oneofModel) + } + + c.graph.Types = append(c.graph.Types, model) + return nil +} + +func (c *jvmTypeCollector) collectEnum(enum *protogen.Enum) error { + if enum == nil { + return nil + } + if c.seenEnums[enum.Desc.FullName()] { + return nil + } + c.seenEnums[enum.Desc.FullName()] = true + + owner, err := c.ownerForFile(enum.Desc.ParentFile()) + if err != nil { + return err + } + + publicName := jvmPublicEnumName(enum) + if err := c.checkOwnedPublicName(owner, "public type name", publicName, string(enum.Desc.FullName())); err != nil { + return err + } + + model := JVMType{ + Kind: JVMTypeKindEnum, + ProtoFullName: string(enum.Desc.FullName()), + ProtoName: string(enum.Desc.Name()), + PublicName: publicName, + Owner: owner, + NestingPath: descriptorTypePath(enum.Desc.FullName(), enum.Desc.ParentFile().Package()), + } + for _, value := range enum.Values { + metadata, err := loadEnumValueMetadata(value) + if err != nil { + return err + } + model.EnumValues = append(model.EnumValues, JVMEnumValue{ + ProtoName: string(value.Desc.Name()), + Number: int32(value.Desc.Number()), + Hidden: metadata.Hidden, + }) + } + + c.graph.Types = append(c.graph.Types, model) + return nil +} + +func (c *jvmTypeCollector) collectField(field *protogen.Field, oneofWrappers map[protoreflect.Name]string, parentPublicName string) (JVMField, error) { + typeRef, err := c.typeRefForField(field) + if err != nil { + return JVMField{}, err + } + + model := JVMField{ + ProtoName: string(field.Desc.Name()), + JSONName: field.Desc.JSONName(), + Number: int(field.Desc.Number()), + Type: typeRef, + IsRepeated: field.Desc.IsList() && !field.Desc.IsMap(), + IsMap: field.Desc.IsMap(), + HasPresence: field.Desc.HasPresence(), + IsSchemaRequired: !field.Desc.IsList() && + !field.Desc.IsMap() && + (field.Oneof == nil || field.Oneof.Desc.IsSynthetic()) && + !field.Desc.HasOptionalKeyword(), + } + + if field.Oneof != nil && !field.Oneof.Desc.IsSynthetic() { + model.OneofProtoName = string(field.Oneof.Desc.Name()) + model.OneofWrapperName = oneofWrappers[field.Oneof.Desc.Name()] + model.VariantWrapperName = jvmOneofVariantWrapperName(parentPublicName, string(field.Oneof.Desc.Name()), string(field.Desc.Name())) + } + + if field.Desc.IsMap() && field.Message != nil && len(field.Message.Fields) == 2 { + model.MapKeyScalar = jvmScalarForKind(field.Message.Fields[0].Desc.Kind()) + valueRef, err := c.typeRefForField(field.Message.Fields[1]) + if err != nil { + return JVMField{}, err + } + model.MapValue = &valueRef + } + + return model, nil +} + +func (c *jvmTypeCollector) typeRefForField(field *protogen.Field) (JVMTypeRef, error) { + switch field.Desc.Kind() { + case protoreflect.BoolKind: + return JVMTypeRef{Scalar: JVMScalarBool}, nil + case protoreflect.StringKind: + return JVMTypeRef{Scalar: JVMScalarString}, nil + case protoreflect.BytesKind: + return JVMTypeRef{Scalar: JVMScalarBytes}, nil + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: + return JVMTypeRef{Scalar: JVMScalarInt32}, nil + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + return JVMTypeRef{Scalar: JVMScalarUInt32}, nil + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + return JVMTypeRef{Scalar: JVMScalarInt64}, nil + case protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return JVMTypeRef{Scalar: JVMScalarUInt64}, nil + case protoreflect.FloatKind: + return JVMTypeRef{Scalar: JVMScalarFloat}, nil + case protoreflect.DoubleKind: + return JVMTypeRef{Scalar: JVMScalarDouble}, nil + case protoreflect.EnumKind: + if err := c.collectEnum(field.Enum); err != nil { + return JVMTypeRef{}, err + } + owner, err := c.ownerForFile(field.Enum.Desc.ParentFile()) + if err != nil { + return JVMTypeRef{}, err + } + return JVMTypeRef{ + ProtoFullName: string(field.Enum.Desc.FullName()), + ProtoName: string(field.Enum.Desc.Name()), + PublicName: jvmPublicEnumName(field.Enum), + Owner: owner, + IsEnum: true, + }, nil + case protoreflect.MessageKind: + if field.Message == nil { + return JVMTypeRef{}, nil + } + wkt, ok, err := classifyJVMWellKnownType(field.Message.Desc.FullName()) + if err != nil { + return JVMTypeRef{}, err + } + owner, ownerErr := c.ownerForFile(field.Message.Desc.ParentFile()) + if ownerErr != nil { + return JVMTypeRef{}, ownerErr + } + if ok { + return JVMTypeRef{ + ProtoFullName: string(field.Message.Desc.FullName()), + ProtoName: string(field.Message.Desc.Name()), + Owner: owner, + WellKnownType: wkt, + IsMessage: true, + }, nil + } + if err := c.collectMessage(field.Message); err != nil { + return JVMTypeRef{}, err + } + return JVMTypeRef{ + ProtoFullName: string(field.Message.Desc.FullName()), + ProtoName: string(field.Message.Desc.Name()), + PublicName: jvmPublicTypeName(field.Message), + Owner: owner, + IsMessage: true, + }, nil + default: + return JVMTypeRef{}, nil + } +} + +func (c *jvmTypeCollector) ownerForFile(file protoreflect.FileDescriptor) (JVMTypeOwner, error) { + owner := newJVMTypeOwner(file.Path(), string(file.Package()), c.rootProtoPath) + if owner.IsCurrentFile { + return owner, nil + } + + for _, existing := range c.graph.Imports { + if existing.ProtoPath == owner.ProtoPath { + return owner, nil + } + } + c.graph.Imports = append(c.graph.Imports, JVMImport{ + ProtoPath: owner.ProtoPath, + GeneratedFilenamePrefix: owner.GeneratedFilenamePrefix, + ProtoPackage: owner.ProtoPackage, + }) + return owner, nil +} + +func (c *jvmTypeCollector) checkOwnedPublicName(owner JVMTypeOwner, category, name, ownerKey string) error { + return jvmCheckOwnedNameCollision(c.ownedPublicNames, owner.GeneratedFilenamePrefix, category, name, ownerKey) +} + +func newJVMTypeOwner(protoPath, protoPackage, currentProtoPath string) JVMTypeOwner { + return JVMTypeOwner{ + ProtoPath: protoPath, + IsCurrentFile: protoPath == currentProtoPath, + GeneratedFilenamePrefix: jvmGeneratedFilenamePrefixForProtoPath(protoPath), + ProtoPackage: protoPackage, + } +} + +func classifyJVMWellKnownType(fullName protoreflect.FullName) (JVMWellKnownType, bool, error) { + switch fullName { + case "google.protobuf.Any": + return JVMWellKnownTypeAny, true, nil + case "google.protobuf.Empty": + return JVMWellKnownTypeEmpty, true, nil + case "google.protobuf.Timestamp": + return JVMWellKnownTypeTimestamp, true, nil + case "google.protobuf.Duration": + return JVMWellKnownTypeDuration, true, nil + case "google.protobuf.FieldMask": + return JVMWellKnownTypeFieldMask, true, nil + case "google.protobuf.Struct": + return JVMWellKnownTypeStruct, true, nil + case "google.protobuf.Value": + return JVMWellKnownTypeValue, true, nil + case "google.protobuf.ListValue": + return JVMWellKnownTypeListValue, true, nil + case "google.protobuf.BoolValue": + return JVMWellKnownTypeBoolValue, true, nil + case "google.protobuf.StringValue": + return JVMWellKnownTypeStringValue, true, nil + case "google.protobuf.BytesValue": + return JVMWellKnownTypeBytesValue, true, nil + case "google.protobuf.Int32Value": + return JVMWellKnownTypeInt32Value, true, nil + case "google.protobuf.UInt32Value": + return JVMWellKnownTypeUInt32Value, true, nil + case "google.protobuf.Int64Value": + return JVMWellKnownTypeInt64Value, true, nil + case "google.protobuf.UInt64Value": + return JVMWellKnownTypeUInt64Value, true, nil + case "google.protobuf.FloatValue": + return JVMWellKnownTypeFloatValue, true, nil + case "google.protobuf.DoubleValue": + return JVMWellKnownTypeDoubleValue, true, nil + default: + if isJVMGoogleWellKnownType(fullName) { + return JVMWellKnownTypeNone, true, fmt.Errorf("well-known type %q is not supported", fullName) + } + return JVMWellKnownTypeNone, false, nil + } +} + +func isJVMGoogleWellKnownType(fullName protoreflect.FullName) bool { + return strings.HasPrefix(string(fullName), "google.protobuf.") +} + +func jvmScalarForKind(kind protoreflect.Kind) JVMScalar { + switch kind { + case protoreflect.BoolKind: + return JVMScalarBool + case protoreflect.StringKind: + return JVMScalarString + case protoreflect.BytesKind: + return JVMScalarBytes + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: + return JVMScalarInt32 + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + return JVMScalarUInt32 + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + return JVMScalarInt64 + case protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return JVMScalarUInt64 + case protoreflect.FloatKind: + return JVMScalarFloat + case protoreflect.DoubleKind: + return JVMScalarDouble + default: + return JVMScalarUnknown + } +} diff --git a/internal/codegen/jvm_model_test.go b/internal/codegen/jvm_model_test.go index 13686d3..727ae9d 100644 --- a/internal/codegen/jvm_model_test.go +++ b/internal/codegen/jvm_model_test.go @@ -23,6 +23,329 @@ func TestJVMModel_StructuralIRDoesNotContainSDKTypes(t *testing.T) { assertIRTypeHasNoPackageSubstring(t, reflect.TypeOf(JVMFileModel{}), "modelcontextprotocol") } +func TestCollectJVMFileModel_PreservesSharedToolSemantics(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] + if file == nil { + t.Fatal("example proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageKotlin}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + jvm, err := CollectJVMFileModel(file, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel: %v", err) + } + + if got, want := jvm.Language, LanguageKotlin; got != want { + t.Fatalf("Language = %q, want %q", got, want) + } + if got, want := jvm.ProtoPath, shared.ProtoPath; got != want { + t.Fatalf("ProtoPath = %q, want %q", got, want) + } + if got, want := jvm.GeneratedFilenamePrefix, shared.GeneratedFilenamePrefix; got != want { + t.Fatalf("GeneratedFilenamePrefix = %q, want %q", got, want) + } + if got, want := jvm.ProtoPackage, "internal.testproto.example.v1"; got != want { + t.Fatalf("ProtoPackage = %q, want %q", got, want) + } + if len(jvm.Services) != len(shared.Services) { + t.Fatalf("service count = %d, want %d", len(jvm.Services), len(shared.Services)) + } + + sharedService := shared.Services[0] + jvmService := jvm.Services[0] + if got, want := jvmService.Namespace, sharedService.Namespace; got != want { + t.Fatalf("Namespace = %q, want %q", got, want) + } + if got, want := jvmService.HandlerName, "ExampleAPIToolHandler"; got != want { + t.Fatalf("HandlerName = %q, want %q", got, want) + } + if got, want := jvmService.RegisterName, "registerExampleAPITools"; got != want { + t.Fatalf("RegisterName = %q, want %q", got, want) + } + if len(jvmService.Methods) != len(sharedService.Methods) { + t.Fatalf("method count = %d, want %d", len(jvmService.Methods), len(sharedService.Methods)) + } + + sharedMethods := make(map[string]MethodModel, len(sharedService.Methods)) + for _, method := range sharedService.Methods { + sharedMethods[method.ProtoName] = method + } + createReport := findJVMMethodByProtoName(t, jvmService, "CreateReport") + sharedCreate := sharedMethods["CreateReport"] + if got, want := createReport.ToolName, sharedCreate.Name; got != want { + t.Fatalf("ToolName = %q, want %q", got, want) + } + if got, want := createReport.MethodName, "createReport"; got != want { + t.Fatalf("MethodName = %q, want %q", got, want) + } + if got, want := createReport.SchemaConst, "EXAMPLE_API_CREATE_REPORT"; got != want { + t.Fatalf("SchemaConst = %q, want %q", got, want) + } + if got, want := createReport.InputSchemaJSON, sharedCreate.InputSchemaJSON; got != want { + t.Fatalf("InputSchemaJSON mismatch: got %q want %q", got, want) + } + if got, want := createReport.OutputSchemaJSON, sharedCreate.OutputSchemaJSON; got != want { + t.Fatalf("OutputSchemaJSON mismatch: got %q want %q", got, want) + } + if got, want := createReport.Input.PublicName, "CreateReportRequest"; got != want { + t.Fatalf("Input.PublicName = %q, want %q", got, want) + } + if !createReport.Input.Owner.IsCurrentFile { + t.Fatal("Input owner should be current file") + } + + health := findJVMMethodByProtoName(t, jvmService, "Ping") + if got, want := health.ToolName, "Health"; got != want { + t.Fatalf("Ping ToolName = %q, want %q", got, want) + } + if got, want := health.MethodName, "ping"; got != want { + t.Fatalf("Ping MethodName = %q, want %q", got, want) + } +} + +func TestCollectJVMFileModel_PreservesAnnotationsAndIcons(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/metadata.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/metadata;metadatav1";`, + `import "mcp/options/v1/options.proto";`, + `message Request {}`, + `message Response {}`, + `service MetadataService {`, + ` option (mcp.options.v1.service) = {`, + ` namespace: "metadata"`, + ` icons: [{`, + ` src: "https://example.com/service.svg"`, + ` mime_type: "image/svg+xml"`, + ` sizes: "64x64"`, + ` theme: "light"`, + ` }]`, + ` };`, + ``, + ` rpc Visible(Request) returns (Response) {`, + ` option (mcp.options.v1.method) = {`, + ` annotations: { read_only_hint: true open_world_hint: false }`, + ` };`, + ` }`, + ``, + ` rpc OverrideIcon(Request) returns (Response) {`, + ` option (mcp.options.v1.method) = {`, + ` icons: [{`, + ` src: "https://example.com/method.png"`, + ` mime_type: "image/png"`, + ` sizes: "32x32"`, + ` theme: "dark"`, + ` }]`, + ` };`, + ` }`, + `}`, + "", + }, "\n"), + }, "test/v1/metadata.proto") + file := plugin.FilesByPath["test/v1/metadata.proto"] + if file == nil { + t.Fatal("metadata proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageJava}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + jvm, err := CollectJVMFileModel(file, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel: %v", err) + } + + service := jvm.Services[0] + visible := findJVMMethodByProtoName(t, service, "Visible") + if visible.Annotations == nil { + t.Fatal("Visible annotations should be preserved") + } + if !visible.Annotations.GetReadOnlyHint() { + t.Fatal("Visible read_only_hint should be true") + } + if visible.Annotations.OpenWorldHint == nil || visible.Annotations.GetOpenWorldHint() { + t.Fatal("Visible open_world_hint should be explicitly false") + } + if len(visible.Icons) != 1 || visible.Icons[0].GetSrc() != "https://example.com/service.svg" { + t.Fatalf("Visible should inherit service icon, got %+v", visible.Icons) + } + + override := findJVMMethodByProtoName(t, service, "OverrideIcon") + if len(override.Icons) != 1 || override.Icons[0].GetSrc() != "https://example.com/method.png" { + t.Fatalf("OverrideIcon should preserve method icon, got %+v", override.Icons) + } +} + +func TestCollectJVMFileModel_TracksRequirednessNullabilityAndOneofShape(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] + if file == nil { + t.Fatal("example proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageJava}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + jvm, err := CollectJVMFileModel(file, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel: %v", err) + } + + createReq := findJVMTypeByProtoName(t, jvm.Types, "internal.testproto.example.v1.CreateReportRequest") + city := findJVMFieldByProtoName(t, createReq, "city") + if !city.IsSchemaRequired { + t.Fatal("singular non-optional field should be schema-required") + } + units := findJVMFieldByProtoName(t, createReq, "units") + if !units.HasPresence { + t.Fatal("proto3 optional field should track presence") + } + if units.IsSchemaRequired { + t.Fatal("proto3 optional field should not be schema-required") + } + labels := findJVMFieldByProtoName(t, createReq, "labels") + if !labels.IsRepeated || labels.IsSchemaRequired { + t.Fatalf("repeated field shape mismatch: repeated=%t required=%t", labels.IsRepeated, labels.IsSchemaRequired) + } + + advanced := findJVMTypeByProtoName(t, jvm.Types, "internal.testproto.example.v1.DescribeAdvancedShapesRequest") + mapField := findJVMFieldByProtoName(t, advanced, "labels") + if !mapField.IsMap { + t.Fatal("labels should be a map field") + } + if got, want := mapField.MapKeyScalar, JVMScalarString; got != want { + t.Fatalf("labels map key scalar = %q, want %q", got, want) + } + if mapField.MapValue == nil || mapField.MapValue.Scalar != JVMScalarString { + t.Fatalf("labels map value = %+v, want string scalar", mapField.MapValue) + } + if mapField.IsSchemaRequired { + t.Fatal("map field should not be schema-required") + } + observedAt := findJVMFieldByProtoName(t, advanced, "observed_at") + if got, want := observedAt.Type.WellKnownType, JVMWellKnownTypeTimestamp; got != want { + t.Fatalf("observed_at WKT = %q, want %q", got, want) + } + + selector := findJVMOneofByProtoName(t, advanced, "selector") + if got, want := selector.WrapperName, "DescribeAdvancedShapesRequestSelectorVariant"; got != want { + t.Fatalf("selector wrapper = %q, want %q", got, want) + } + if len(selector.Variants) != 3 { + t.Fatalf("selector variant count = %d, want 3", len(selector.Variants)) + } + cityID := findJVMVariantByProtoName(t, selector, "city_id") + if got, want := cityID.WrapperName, "DescribeAdvancedShapesRequestSelectorCityIdVariant"; got != want { + t.Fatalf("city_id wrapper = %q, want %q", got, want) + } + if got, want := cityID.Type.Scalar, JVMScalarInt64; got != want { + t.Fatalf("city_id scalar = %q, want %q", got, want) + } + + cityIDField := findJVMFieldByProtoName(t, advanced, "city_id") + if got, want := cityIDField.OneofProtoName, "selector"; got != want { + t.Fatalf("city_id oneof = %q, want %q", got, want) + } + if cityIDField.IsSchemaRequired { + t.Fatal("oneof field should not be schema-required") + } + if got, want := cityIDField.VariantWrapperName, cityID.WrapperName; got != want { + t.Fatalf("city_id variant wrapper = %q, want %q", got, want) + } +} + +func TestCollectJVMFileModel_RejectsNonJVMTarget(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] + if file == nil { + t.Fatal("example proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageGo}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + _, err = CollectJVMFileModel(file, shared) + if err == nil { + t.Fatal("CollectJVMFileModel unexpectedly succeeded for go target") + } + if !strings.Contains(err.Error(), "jvm model requires lang=kotlin or lang=java") { + t.Fatalf("CollectJVMFileModel error = %v, want non-JVM target rejection", err) + } +} + +func findJVMMethodByProtoName(t *testing.T, service JVMServiceModel, protoName string) JVMMethodModel { + t.Helper() + + for _, method := range service.Methods { + if method.ProtoName == protoName { + return method + } + } + + t.Fatalf("jvm method %q not found", protoName) + return JVMMethodModel{} +} + +func findJVMTypeByProtoName(t *testing.T, graph JVMTypeGraph, protoFullName string) JVMType { + t.Helper() + + for _, typ := range graph.Types { + if typ.ProtoFullName == protoFullName { + return typ + } + } + + t.Fatalf("jvm type %q not found", protoFullName) + return JVMType{} +} + +func findJVMFieldByProtoName(t *testing.T, typ JVMType, protoName string) JVMField { + t.Helper() + + for _, field := range typ.Fields { + if field.ProtoName == protoName { + return field + } + } + + t.Fatalf("jvm field %q not found in %q", protoName, typ.ProtoFullName) + return JVMField{} +} + +func findJVMOneofByProtoName(t *testing.T, typ JVMType, protoName string) JVMOneof { + t.Helper() + + for _, oneof := range typ.Oneofs { + if oneof.ProtoName == protoName { + return oneof + } + } + + t.Fatalf("jvm oneof %q not found in %q", protoName, typ.ProtoFullName) + return JVMOneof{} +} + +func findJVMVariantByProtoName(t *testing.T, oneof JVMOneof, protoName string) JVMOneofVariant { + t.Helper() + + for _, variant := range oneof.Variants { + if variant.ProtoName == protoName { + return variant + } + } + + t.Fatalf("jvm oneof variant %q not found in %q", protoName, oneof.ProtoName) + return JVMOneofVariant{} +} + func assertIRTypeHasNoPackageSubstring(t *testing.T, typ reflect.Type, disallowed string) { t.Helper() From 01d363c8ab456b3273d425737ba39d2b0b34bc75 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 00:06:20 +0300 Subject: [PATCH 06/74] feat(01-03): route JVM targets through shared model collection --- internal/codegen/generator.go | 19 +++++++++++++++++++ internal/codegen/generator_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 884b0b2..0072cb2 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -15,6 +15,8 @@ func Generate(plugin *protogen.Plugin, opts Options) error { if err := emitPythonSupportFiles(plugin); err != nil { return err } + case LanguageKotlin: + case LanguageJava: default: return fmt.Errorf("unsupported lang %q", opts.Language) } @@ -38,6 +40,23 @@ func Generate(plugin *protogen.Plugin, opts Options) error { return nil } + switch opts.Language { + case LanguageKotlin, LanguageJava: + for _, file := range plugin.Files { + if !file.Generate { + continue + } + model, err := CollectFileModel(file, opts) + if err != nil { + return err + } + if _, err := CollectJVMFileModel(file, model); err != nil { + return err + } + } + return nil + } + for _, file := range plugin.Files { if !file.Generate { continue diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index b915456..04cfd09 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -41,6 +41,34 @@ func TestGeneratePythonExampleGolden(t *testing.T) { } } +func TestGenerate_JVMTargetsCollectWithoutOutput(t *testing.T) { + tests := []struct { + name string + language Language + }{ + { + name: "kotlin", + language: LanguageKotlin, + }, + { + name: "java", + language: LanguageJava, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: tt.language}); err != nil { + t.Fatalf("Generate: %v", err) + } + if len(plugin.Response().GetFile()) != 0 { + t.Fatalf("JVM foundation dispatch emitted %d files, want 0", len(plugin.Response().GetFile())) + } + }) + } +} + func TestGenerate_PythonEmitsMCPNamespaceBridge(t *testing.T) { plugin := newExampleProtogenPlugin(t) if err := Generate(plugin, Options{ From 48d7db9260b30fe408af9bc77f07c7ed4f7d7453 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 00:07:08 +0300 Subject: [PATCH 07/74] test(01-03): prove JVM generation fails before output --- internal/codegen/generator_test.go | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 04cfd09..c529118 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -414,6 +414,54 @@ func TestGenerate_PythonUnsupportedDescriptorFails(t *testing.T) { } } +func TestGenerate_JVMUnsupportedDescriptorFailsBeforeOutput(t *testing.T) { + tests := []struct { + name string + language Language + }{ + { + name: "kotlin", + language: LanguageKotlin, + }, + { + name: "java", + language: LanguageJava, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/unsupported.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/unsupported;unsupportedv1";`, + `import "google/protobuf/type.proto";`, + `message BuildUnsupportedRequest {`, + ` google.protobuf.Type payload = 1;`, + `}`, + `message BuildUnsupportedResponse {}`, + `service UnsupportedAPI {`, + ` rpc BuildUnsupported(BuildUnsupportedRequest) returns (BuildUnsupportedResponse);`, + `}`, + "", + }, "\n"), + }, "test/v1/unsupported.proto") + + err := Generate(plugin, Options{Language: tt.language}) + if err == nil { + t.Fatal("Generate unexpectedly succeeded") + } + if !strings.Contains(err.Error(), `well-known type "google.protobuf.Type" is not supported`) { + t.Fatalf("unexpected generator failure: %v", err) + } + if len(plugin.Response().GetFile()) != 0 { + t.Fatalf("JVM unsupported descriptor emitted %d files, want 0", len(plugin.Response().GetFile())) + } + }) + } +} + func TestGenerate_PythonUnsupportedRuntimeBetterprotoFails(t *testing.T) { root := repoRoot(t) @@ -496,6 +544,51 @@ func TestGenerate_PythonStreamingRPCFails(t *testing.T) { } } +func TestGenerate_JVMStreamingRPCFailsBeforeOutput(t *testing.T) { + tests := []struct { + name string + language Language + }{ + { + name: "kotlin", + language: LanguageKotlin, + }, + { + name: "java", + language: LanguageJava, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/streaming.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/streaming;streamingv1";`, + `message StreamingRequest {}`, + `message StreamingResponse {}`, + `service StreamingAPI {`, + ` rpc Watch(StreamingRequest) returns (stream StreamingResponse);`, + `}`, + "", + }, "\n"), + }, "test/v1/streaming.proto") + + err := Generate(plugin, Options{Language: tt.language}) + if err == nil { + t.Fatal("Generate unexpectedly succeeded") + } + if !strings.Contains(err.Error(), `streaming RPC is not supported`) { + t.Fatalf("unexpected generator failure: %v", err) + } + if len(plugin.Response().GetFile()) != 0 { + t.Fatalf("JVM streaming RPC emitted %d files, want 0", len(plugin.Response().GetFile())) + } + }) + } +} + func TestGenerate_RejectsUnknownCustomParamAndAllowsBuiltInParams(t *testing.T) { root := repoRoot(t) From 44e7b50b0b1de43ff0b39ae10e3b12abaa76a21e Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 00:07:55 +0300 Subject: [PATCH 08/74] docs(01-03): document shared JVM foundation status --- AGENTS.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5bed9d2..13c4bd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,8 @@ decision-consistent with the current architecture unless explicitly revised. - `easyp.test.yaml`: development and test config for fixture generation - `mcp/options/v1/options.proto`: custom protobuf options for MCP metadata - `internal/codegen`: code generation logic +- `internal/codegen/jvm_*.go`: shared JVM semantic model, naming, and collector + foundation used by future Kotlin/Java renderers - `internal/examplemcp`: reusable example MCP server wiring and stdio smoke test - `internal/schema`: protobuf descriptor to JSON Schema conversion - `internal/testproto`: protobuf fixtures and generated code used in repository tests @@ -112,8 +114,13 @@ decision-consistent with the current architecture unless explicitly revised. - Implemented: - `cmd/protoc-gen-mcp` plugin scaffold and generated `*.mcp.go` bindings - - typed plugin option parsing for `lang=go|python` and + - typed plugin option parsing for `lang=go|python|kotlin|java` and `python_runtime=google.protobuf|betterproto|grpclib` + - shared JVM foundation for `lang=kotlin` and `lang=java`: parser and + generator dispatch accept both targets, collect SDK-neutral `internal/codegen/jvm_*.go` + models, preserve existing `FileModel` schema JSON/annotations/icons/type + semantics, and emit no JVM files until Kotlin/Java renderers are implemented + in later phases - single-source custom generator option handling through `protogen.Options.ParamFunc`, with fail-fast rejection of unknown `protoc-gen-mcp` params @@ -208,6 +215,7 @@ decision-consistent with the current architecture unless explicitly revised. proto3 `optional` scalar/enum fields - Verified: - `easyp` lint and generation flows for `mcp` and `internal/testproto` + - `go test ./internal/codegen -count=1` for generator, Go/Python, and shared JVM foundation coverage - `go test ./...` - stdio smoke tests via `internal/examplemcp/stdio_test.go` - Python stdio integration coverage for the shared server: From 63f7b1ab4320ad61a7251b60c06d77df3c437632 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 01:01:39 +0300 Subject: [PATCH 09/74] fix(01): close JVM foundation review findings --- internal/codegen/generator.go | 6 ++-- internal/codegen/generator_test.go | 6 ++++ internal/codegen/jvm_collect.go | 2 +- internal/codegen/jvm_model_test.go | 57 ++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 0072cb2..18dce1f 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -12,9 +12,6 @@ func Generate(plugin *protogen.Plugin, opts Options) error { switch opts.Language { case LanguageGo: case LanguagePython: - if err := emitPythonSupportFiles(plugin); err != nil { - return err - } case LanguageKotlin: case LanguageJava: default: @@ -27,6 +24,9 @@ func Generate(plugin *protogen.Plugin, opts Options) error { if err != nil { return err } + if err := emitPythonSupportFiles(plugin); err != nil { + return err + } for _, file := range orderedFiles { model := models[file.Desc.Path()] if !pythonModelRequiresOutput(model) { diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index c529118..956da3c 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -412,6 +412,9 @@ func TestGenerate_PythonUnsupportedDescriptorFails(t *testing.T) { if !strings.Contains(err.Error(), `well-known type "google.protobuf.Type" is not supported`) { t.Fatalf("unexpected generator failure: %v", err) } + if len(plugin.Response().GetFile()) != 0 { + t.Fatalf("python unsupported descriptor emitted %d files, want 0", len(plugin.Response().GetFile())) + } } func TestGenerate_JVMUnsupportedDescriptorFailsBeforeOutput(t *testing.T) { @@ -542,6 +545,9 @@ func TestGenerate_PythonStreamingRPCFails(t *testing.T) { if !strings.Contains(err.Error(), `streaming RPC is not supported`) { t.Fatalf("unexpected generator failure: %v", err) } + if len(plugin.Response().GetFile()) != 0 { + t.Fatalf("python streaming RPC emitted %d files, want 0", len(plugin.Response().GetFile())) + } } func TestGenerate_JVMStreamingRPCFailsBeforeOutput(t *testing.T) { diff --git a/internal/codegen/jvm_collect.go b/internal/codegen/jvm_collect.go index bfab626..e41fd9f 100644 --- a/internal/codegen/jvm_collect.go +++ b/internal/codegen/jvm_collect.go @@ -33,7 +33,7 @@ func CollectJVMFileModel(file *protogen.File, model FileModel) (JVMFileModel, er jvmModel := JVMFileModel{ Language: model.Options.Language, ProtoPath: model.ProtoPath, - GeneratedFilenamePrefix: model.GeneratedFilenamePrefix, + GeneratedFilenamePrefix: jvmGeneratedFilenamePrefixForProtoPath(model.ProtoPath), ProtoPackage: string(file.Desc.Package()), Services: make([]JVMServiceModel, 0, len(model.Services)), Types: typeGraph, diff --git a/internal/codegen/jvm_model_test.go b/internal/codegen/jvm_model_test.go index 727ae9d..ba717d9 100644 --- a/internal/codegen/jvm_model_test.go +++ b/internal/codegen/jvm_model_test.go @@ -261,6 +261,63 @@ func TestCollectJVMFileModel_TracksRequirednessNullabilityAndOneofShape(t *testi } } +func TestCollectJVMFileModel_NormalizesFilenamePrefixesConsistently(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/shared-file.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/sharedfile;sharedfilev1";`, + `message SharedRequest {}`, + `message SharedResponse {}`, + "", + }, "\n"), + "test/v1/service-file.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/servicefile;servicefilev1";`, + `import "test/v1/shared-file.proto";`, + `service ServiceAPI {`, + ` rpc UseShared(SharedRequest) returns (SharedResponse);`, + `}`, + "", + }, "\n"), + }, "test/v1/service-file.proto") + file := plugin.FilesByPath["test/v1/service-file.proto"] + if file == nil { + t.Fatal("service-file proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageJava}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + jvm, err := CollectJVMFileModel(file, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel: %v", err) + } + + if got, want := jvm.GeneratedFilenamePrefix, "test/v1/service_file"; got != want { + t.Fatalf("GeneratedFilenamePrefix = %q, want %q", got, want) + } + if got, want := jvm.Types.CurrentFile.GeneratedFilenamePrefix, "test/v1/service_file"; got != want { + t.Fatalf("CurrentFile.GeneratedFilenamePrefix = %q, want %q", got, want) + } + if len(jvm.Types.Imports) != 1 { + t.Fatalf("import count = %d, want 1", len(jvm.Types.Imports)) + } + if got, want := jvm.Types.Imports[0].GeneratedFilenamePrefix, "test/v1/shared_file"; got != want { + t.Fatalf("Imports[0].GeneratedFilenamePrefix = %q, want %q", got, want) + } + + method := findJVMMethodByProtoName(t, jvm.Services[0], "UseShared") + if got, want := method.Input.Owner.GeneratedFilenamePrefix, "test/v1/shared_file"; got != want { + t.Fatalf("Input.Owner.GeneratedFilenamePrefix = %q, want %q", got, want) + } + if got, want := method.Output.Owner.GeneratedFilenamePrefix, "test/v1/shared_file"; got != want { + t.Fatalf("Output.Owner.GeneratedFilenamePrefix = %q, want %q", got, want) + } +} + func TestCollectJVMFileModel_RejectsNonJVMTarget(t *testing.T) { plugin := newExampleProtogenPlugin(t) file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] From 6440e369a196f1fbf4656356263d2d85689cb338 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 12:08:08 +0300 Subject: [PATCH 10/74] feat(02-01): carry task support through JVM IR --- internal/codegen/collect.go | 1 + internal/codegen/jvm_collect.go | 1 + internal/codegen/jvm_model.go | 1 + internal/codegen/jvm_model_test.go | 75 ++++++++++++++++++++++++++++++ internal/codegen/model.go | 1 + 5 files changed, 79 insertions(+) diff --git a/internal/codegen/collect.go b/internal/codegen/collect.go index 95d308e..6aeea60 100644 --- a/internal/codegen/collect.go +++ b/internal/codegen/collect.go @@ -91,6 +91,7 @@ func CollectFileModel(file *protogen.File, opts Options) (FileModel, error) { OutputSchemaJSON: outputSchemaJSON, Annotations: methodMetadata.Annotations, Icons: methodMetadata.Icons, + TaskSupport: methodMetadata.TaskSupport, } if len(methodModel.Icons) == 0 { methodModel.Icons = serviceModel.Icons diff --git a/internal/codegen/jvm_collect.go b/internal/codegen/jvm_collect.go index e41fd9f..62713ee 100644 --- a/internal/codegen/jvm_collect.go +++ b/internal/codegen/jvm_collect.go @@ -77,6 +77,7 @@ func newJVMMethodModel(serviceName string, method MethodModel, descriptorMethod OutputSchemaJSON: method.OutputSchemaJSON, Annotations: method.Annotations, Icons: method.Icons, + TaskSupport: method.TaskSupport, } if descriptorMethod != nil { methodModel.Input = newJVMTypeRefFromMessage(descriptorMethod.Input, currentProtoPath) diff --git a/internal/codegen/jvm_model.go b/internal/codegen/jvm_model.go index 184c833..6125335 100644 --- a/internal/codegen/jvm_model.go +++ b/internal/codegen/jvm_model.go @@ -40,6 +40,7 @@ type JVMMethodModel struct { OutputSchemaJSON string Annotations *mcpoptionsv1.ToolAnnotations Icons []*mcpoptionsv1.Icon + TaskSupport mcpoptionsv1.TaskSupport } type JVMTypeGraph struct { diff --git a/internal/codegen/jvm_model_test.go b/internal/codegen/jvm_model_test.go index ba717d9..feadb8a 100644 --- a/internal/codegen/jvm_model_test.go +++ b/internal/codegen/jvm_model_test.go @@ -4,6 +4,8 @@ import ( "reflect" "strings" "testing" + + mcpoptionsv1 "github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1" ) func TestJVMModel_StructuralIRDoesNotContainRawProtogenDescriptors(t *testing.T) { @@ -182,6 +184,66 @@ func TestCollectJVMFileModel_PreservesAnnotationsAndIcons(t *testing.T) { } } +func TestCollectJVMFileModel_PreservesTaskSupport(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/task_support.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/tasksupport;tasksupportv1";`, + `import "mcp/options/v1/options.proto";`, + `message Request {}`, + `message Response {}`, + `service TaskSupportService {`, + ` rpc OptionalTask(Request) returns (Response) {`, + ` option (mcp.options.v1.method) = {`, + ` execution: { task_support: TASK_SUPPORT_OPTIONAL }`, + ` };`, + ` }`, + ` rpc RequiredTask(Request) returns (Response) {`, + ` option (mcp.options.v1.method) = {`, + ` execution: { task_support: TASK_SUPPORT_REQUIRED }`, + ` };`, + ` }`, + ` rpc PlainTask(Request) returns (Response);`, + `}`, + "", + }, "\n"), + }, "test/v1/task_support.proto") + file := plugin.FilesByPath["test/v1/task_support.proto"] + if file == nil { + t.Fatal("task_support proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageKotlin}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + jvm, err := CollectJVMFileModel(file, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel: %v", err) + } + + sharedService := shared.Services[0] + for _, tc := range []struct { + protoName string + want mcpoptionsv1.TaskSupport + }{ + {protoName: "OptionalTask", want: mcpoptionsv1.TaskSupport_TASK_SUPPORT_OPTIONAL}, + {protoName: "RequiredTask", want: mcpoptionsv1.TaskSupport_TASK_SUPPORT_REQUIRED}, + {protoName: "PlainTask", want: mcpoptionsv1.TaskSupport_TASK_SUPPORT_NONE}, + } { + sharedMethod := findSharedMethodByProtoName(t, sharedService, tc.protoName) + if got := sharedMethod.TaskSupport; got != tc.want { + t.Fatalf("shared %s TaskSupport = %v, want %v", tc.protoName, got, tc.want) + } + + jvmMethod := findJVMMethodByProtoName(t, jvm.Services[0], tc.protoName) + if got := jvmMethod.TaskSupport; got != tc.want { + t.Fatalf("jvm %s TaskSupport = %v, want %v", tc.protoName, got, tc.want) + } + } +} + func TestCollectJVMFileModel_TracksRequirednessNullabilityAndOneofShape(t *testing.T) { plugin := newExampleProtogenPlugin(t) file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] @@ -338,6 +400,19 @@ func TestCollectJVMFileModel_RejectsNonJVMTarget(t *testing.T) { } } +func findSharedMethodByProtoName(t *testing.T, service ServiceModel, protoName string) MethodModel { + t.Helper() + + for _, method := range service.Methods { + if method.ProtoName == protoName { + return method + } + } + + t.Fatalf("shared method %q not found", protoName) + return MethodModel{} +} + func findJVMMethodByProtoName(t *testing.T, service JVMServiceModel, protoName string) JVMMethodModel { t.Helper() diff --git a/internal/codegen/model.go b/internal/codegen/model.go index 5db1dc4..21099fd 100644 --- a/internal/codegen/model.go +++ b/internal/codegen/model.go @@ -36,6 +36,7 @@ type MethodModel struct { OutputSchemaJSON string Annotations *mcpoptionsv1.ToolAnnotations Icons []*mcpoptionsv1.Icon + TaskSupport mcpoptionsv1.TaskSupport } type TypeRef struct { From 1f9f4d5304b8d4d0059874453318f1f8fb86d8c4 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 12:08:21 +0300 Subject: [PATCH 11/74] feat(02-01): add JVM package resolver --- internal/codegen/jvm_package_resolver.go | 112 ++++++++ internal/codegen/jvm_package_resolver_test.go | 270 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 internal/codegen/jvm_package_resolver.go create mode 100644 internal/codegen/jvm_package_resolver_test.go diff --git a/internal/codegen/jvm_package_resolver.go b/internal/codegen/jvm_package_resolver.go new file mode 100644 index 0000000..b598015 --- /dev/null +++ b/internal/codegen/jvm_package_resolver.go @@ -0,0 +1,112 @@ +package codegen + +import ( + "path" + "strings" + "unicode" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +type jvmResolvedFilePackage struct { + Package string + MultipleFiles bool + OuterClassName string +} + +type jvmResolvedTypeRef struct { + Package string + ImportPath string + Expr string + IsCurrentFile bool +} + +func resolveJVMFilePackage(file *protogen.File) (jvmResolvedFilePackage, error) { + return resolveJVMDescriptorFilePackage(file.Desc) +} + +func resolveJVMDescriptorFilePackage(file protoreflect.FileDescriptor) (jvmResolvedFilePackage, error) { + options, _ := file.Options().(*descriptorpb.FileOptions) + javaPackage := strings.TrimSpace(options.GetJavaPackage()) + if javaPackage == "" { + javaPackage = string(file.Package()) + } + + outerClassName := strings.TrimSpace(options.GetJavaOuterClassname()) + if outerClassName == "" { + outerClassName = defaultJVMOuterClassName(file.Path()) + } + + return jvmResolvedFilePackage{ + Package: javaPackage, + MultipleFiles: options.GetJavaMultipleFiles(), + OuterClassName: outerClassName, + }, nil +} + +func resolveJVMMessageTypeRef(message *protogen.Message, currentProtoPath string) (jvmResolvedTypeRef, error) { + if message == nil { + return jvmResolvedTypeRef{}, nil + } + return resolveJVMTypeRef(message.Desc.ParentFile(), jvmPublicTypeName(message), currentProtoPath) +} + +func resolveJVMEnumTypeRef(enum *protogen.Enum, currentProtoPath string) (jvmResolvedTypeRef, error) { + if enum == nil { + return jvmResolvedTypeRef{}, nil + } + return resolveJVMTypeRef(enum.Desc.ParentFile(), jvmPublicEnumName(enum), currentProtoPath) +} + +func resolveJVMTypeRef(file protoreflect.FileDescriptor, publicName, currentProtoPath string) (jvmResolvedTypeRef, error) { + resolvedFile, err := resolveJVMDescriptorFilePackage(file) + if err != nil { + return jvmResolvedTypeRef{}, err + } + + isCurrentFile := file.Path() == currentProtoPath + expr := publicName + importPath := "" + if resolvedFile.MultipleFiles { + if !isCurrentFile { + importPath = resolvedFile.Package + "." + publicName + } + } else { + expr = resolvedFile.OuterClassName + "." + publicName + if !isCurrentFile { + importPath = resolvedFile.Package + "." + resolvedFile.OuterClassName + } + } + + return jvmResolvedTypeRef{ + Package: resolvedFile.Package, + ImportPath: importPath, + Expr: expr, + IsCurrentFile: isCurrentFile, + }, nil +} + +func defaultJVMOuterClassName(protoPath string) string { + base := strings.TrimSuffix(path.Base(protoPath), path.Ext(protoPath)) + var b strings.Builder + nextUpper := true + for _, r := range base { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + nextUpper = true + continue + } + if nextUpper { + b.WriteRune(unicode.ToUpper(r)) + nextUpper = false + continue + } + b.WriteRune(r) + } + if b.Len() == 0 { + return "ProtoOuterClass" + } + b.WriteString("OuterClass") + return b.String() +} diff --git a/internal/codegen/jvm_package_resolver_test.go b/internal/codegen/jvm_package_resolver_test.go new file mode 100644 index 0000000..508af6f --- /dev/null +++ b/internal/codegen/jvm_package_resolver_test.go @@ -0,0 +1,270 @@ +package codegen + +import ( + "strings" + "testing" + + "google.golang.org/protobuf/compiler/protogen" +) + +func TestResolveJVMFilePackage_UsesJavaPackageAndProtoFallback(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/java_package.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/javapackage;javapackagev1";`, + `option java_package = "com.example.test";`, + `option java_multiple_files = true;`, + `message JavaPackageRequest {}`, + "", + }, "\n"), + "test/v1/proto_fallback.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package fallback.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/protofallback;protofallbackv1";`, + `message ProtoFallbackRequest {}`, + "", + }, "\n"), + }, "test/v1/java_package.proto", "test/v1/proto_fallback.proto") + + javaPackage, err := resolveJVMFilePackage(plugin.FilesByPath["test/v1/java_package.proto"]) + if err != nil { + t.Fatalf("resolve java package: %v", err) + } + if got, want := javaPackage.Package, "com.example.test"; got != want { + t.Fatalf("java package Package = %q, want %q", got, want) + } + if !javaPackage.MultipleFiles { + t.Fatal("java package MultipleFiles should be true") + } + + fallback, err := resolveJVMFilePackage(plugin.FilesByPath["test/v1/proto_fallback.proto"]) + if err != nil { + t.Fatalf("resolve proto fallback: %v", err) + } + if got, want := fallback.Package, "fallback.v1"; got != want { + t.Fatalf("fallback Package = %q, want %q", got, want) + } + if got, want := fallback.OuterClassName, "ProtoFallbackOuterClass"; got != want { + t.Fatalf("fallback OuterClassName = %q, want %q", got, want) + } +} + +func TestResolveJVMMessageTypeRef_RespectsJavaMultipleFilesAndOuterClassname(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/shared.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/shared;sharedv1";`, + `option java_package = "com.example.shared";`, + `option java_multiple_files = true;`, + `message SharedRequest {}`, + "", + }, "\n"), + "test/v1/wrapped.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/wrapped;wrappedv1";`, + `option java_package = "com.example.wrapped";`, + `option java_multiple_files = false;`, + `option java_outer_classname = "WrappedTypes";`, + `message WrappedRequest {}`, + "", + }, "\n"), + "test/v1/service.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/service;servicev1";`, + `option java_package = "com.example.service";`, + `option java_multiple_files = true;`, + `import "test/v1/shared.proto";`, + `import "test/v1/wrapped.proto";`, + `message LocalRequest {}`, + `service ResolverService {`, + ` rpc UseShared(SharedRequest) returns (WrappedRequest);`, + `}`, + "", + }, "\n"), + }, "test/v1/service.proto") + + currentPath := "test/v1/service.proto" + local, err := resolveJVMMessageTypeRef(findMessage(t, plugin.FilesByPath[currentPath], "LocalRequest"), currentPath) + if err != nil { + t.Fatalf("resolve local message: %v", err) + } + if got, want := local.Expr, "LocalRequest"; got != want { + t.Fatalf("local Expr = %q, want %q", got, want) + } + if local.ImportPath != "" { + t.Fatalf("local ImportPath = %q, want empty", local.ImportPath) + } + if !local.IsCurrentFile { + t.Fatal("local IsCurrentFile should be true") + } + + shared, err := resolveJVMMessageTypeRef(findMessage(t, plugin.FilesByPath["test/v1/shared.proto"], "SharedRequest"), currentPath) + if err != nil { + t.Fatalf("resolve shared message: %v", err) + } + if got, want := shared.ImportPath, "com.example.shared.SharedRequest"; got != want { + t.Fatalf("shared ImportPath = %q, want %q", got, want) + } + if got, want := shared.Expr, "SharedRequest"; got != want { + t.Fatalf("shared Expr = %q, want %q", got, want) + } + + wrapped, err := resolveJVMMessageTypeRef(findMessage(t, plugin.FilesByPath["test/v1/wrapped.proto"], "WrappedRequest"), currentPath) + if err != nil { + t.Fatalf("resolve wrapped message: %v", err) + } + if got, want := wrapped.ImportPath, "com.example.wrapped.WrappedTypes"; got != want { + t.Fatalf("wrapped ImportPath = %q, want %q", got, want) + } + if got, want := wrapped.Expr, "WrappedTypes.WrappedRequest"; got != want { + t.Fatalf("wrapped Expr = %q, want %q", got, want) + } +} + +func TestResolveJVMMessageTypeRef_UsesDefaultOuterClassname(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/default_outer.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/defaultouter;defaultouterv1";`, + `option java_package = "com.example.defaultouter";`, + `option java_multiple_files = false;`, + `message DefaultRequest {}`, + "", + }, "\n"), + }, "test/v1/default_outer.proto") + + ref, err := resolveJVMMessageTypeRef(findMessage(t, plugin.FilesByPath["test/v1/default_outer.proto"], "DefaultRequest"), "other.proto") + if err != nil { + t.Fatalf("resolve default outer message: %v", err) + } + if got, want := ref.ImportPath, "com.example.defaultouter.DefaultOuterOuterClass"; got != want { + t.Fatalf("ImportPath = %q, want %q", got, want) + } + if got, want := ref.Expr, "DefaultOuterOuterClass.DefaultRequest"; got != want { + t.Fatalf("Expr = %q, want %q", got, want) + } +} + +func TestResolveJVMMessageTypeRef_CurrentFileSkipsImport(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/current_wrapper.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/current;currentv1";`, + `option java_package = "com.example.current";`, + `option java_multiple_files = false;`, + `option java_outer_classname = "CurrentWrapper";`, + `message CurrentRequest {}`, + "", + }, "\n"), + }, "test/v1/current_wrapper.proto") + + ref, err := resolveJVMMessageTypeRef(findMessage(t, plugin.FilesByPath["test/v1/current_wrapper.proto"], "CurrentRequest"), "test/v1/current_wrapper.proto") + if err != nil { + t.Fatalf("resolve current wrapper message: %v", err) + } + if ref.ImportPath != "" { + t.Fatalf("ImportPath = %q, want empty", ref.ImportPath) + } + if got, want := ref.Expr, "CurrentWrapper.CurrentRequest"; got != want { + t.Fatalf("Expr = %q, want %q", got, want) + } +} + +func TestResolveJVMEnumTypeRef_RespectsJavaMultipleFilesAndOuterClassname(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/enums.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/enums;enumsv1";`, + `option java_package = "com.example.enums";`, + `option java_multiple_files = true;`, + `enum SharedStatus { SHARED_STATUS_UNSPECIFIED = 0; }`, + "", + }, "\n"), + "test/v1/wrapped_enums.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/wrappedenums;wrappedenumsv1";`, + `option java_package = "com.example.enums.wrapped";`, + `option java_multiple_files = false;`, + `option java_outer_classname = "EnumWrapper";`, + `enum WrappedStatus { WRAPPED_STATUS_UNSPECIFIED = 0; }`, + "", + }, "\n"), + "test/v1/local_enums.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/localenums;localenumsv1";`, + `option java_package = "com.example.local";`, + `option java_multiple_files = true;`, + `enum LocalStatus { LOCAL_STATUS_UNSPECIFIED = 0; }`, + "", + }, "\n"), + }, "test/v1/local_enums.proto", "test/v1/enums.proto", "test/v1/wrapped_enums.proto") + + currentPath := "test/v1/local_enums.proto" + local, err := resolveJVMEnumTypeRef(findEnum(t, plugin.FilesByPath[currentPath], "LocalStatus"), currentPath) + if err != nil { + t.Fatalf("resolve local enum: %v", err) + } + if local.ImportPath != "" { + t.Fatalf("local ImportPath = %q, want empty", local.ImportPath) + } + if got, want := local.Expr, "LocalStatus"; got != want { + t.Fatalf("local Expr = %q, want %q", got, want) + } + + shared, err := resolveJVMEnumTypeRef(findEnum(t, plugin.FilesByPath["test/v1/enums.proto"], "SharedStatus"), currentPath) + if err != nil { + t.Fatalf("resolve shared enum: %v", err) + } + if got, want := shared.ImportPath, "com.example.enums.SharedStatus"; got != want { + t.Fatalf("shared ImportPath = %q, want %q", got, want) + } + if got, want := shared.Expr, "SharedStatus"; got != want { + t.Fatalf("shared Expr = %q, want %q", got, want) + } + + wrapped, err := resolveJVMEnumTypeRef(findEnum(t, plugin.FilesByPath["test/v1/wrapped_enums.proto"], "WrappedStatus"), currentPath) + if err != nil { + t.Fatalf("resolve wrapped enum: %v", err) + } + if got, want := wrapped.ImportPath, "com.example.enums.wrapped.EnumWrapper"; got != want { + t.Fatalf("wrapped ImportPath = %q, want %q", got, want) + } + if got, want := wrapped.Expr, "EnumWrapper.WrappedStatus"; got != want { + t.Fatalf("wrapped Expr = %q, want %q", got, want) + } +} + +func findMessage(t *testing.T, file *protogen.File, name string) *protogen.Message { + t.Helper() + + for _, message := range file.Messages { + if string(message.Desc.Name()) == name { + return message + } + } + + t.Fatalf("message %q not found in %s", name, file.Desc.Path()) + return nil +} + +func findEnum(t *testing.T, file *protogen.File, name string) *protogen.Enum { + t.Helper() + + for _, enum := range file.Enums { + if string(enum.Desc.Name()) == name { + return enum + } + } + + t.Fatalf("enum %q not found in %s", name, file.Desc.Path()) + return nil +} From f9a01f1cb9b0b499bc1443e71ad97d0cf906c71b Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 12:10:29 +0300 Subject: [PATCH 12/74] docs(02-01): clarify task support IR fields --- internal/codegen/jvm_model.go | 3 ++- internal/codegen/model.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/codegen/jvm_model.go b/internal/codegen/jvm_model.go index 6125335..44a9c40 100644 --- a/internal/codegen/jvm_model.go +++ b/internal/codegen/jvm_model.go @@ -40,7 +40,8 @@ type JVMMethodModel struct { OutputSchemaJSON string Annotations *mcpoptionsv1.ToolAnnotations Icons []*mcpoptionsv1.Icon - TaskSupport mcpoptionsv1.TaskSupport + // TaskSupport mcpoptionsv1.TaskSupport mirrors the shared method contract. + TaskSupport mcpoptionsv1.TaskSupport } type JVMTypeGraph struct { diff --git a/internal/codegen/model.go b/internal/codegen/model.go index 21099fd..2822c20 100644 --- a/internal/codegen/model.go +++ b/internal/codegen/model.go @@ -36,7 +36,8 @@ type MethodModel struct { OutputSchemaJSON string Annotations *mcpoptionsv1.ToolAnnotations Icons []*mcpoptionsv1.Icon - TaskSupport mcpoptionsv1.TaskSupport + // TaskSupport mcpoptionsv1.TaskSupport preserves execution.task_support. + TaskSupport mcpoptionsv1.TaskSupport } type TypeRef struct { From ff2da0c6fa6d85264fc1c2f9304f131961db61eb Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 12:23:13 +0300 Subject: [PATCH 13/74] feat(02-02): add Kotlin MCP renderer contract --- internal/codegen/kotlin_contract_test.go | 273 +++++++++++++ internal/codegen/render_kotlin.go | 472 +++++++++++++++++++++++ 2 files changed, 745 insertions(+) create mode 100644 internal/codegen/kotlin_contract_test.go create mode 100644 internal/codegen/render_kotlin.go diff --git a/internal/codegen/kotlin_contract_test.go b/internal/codegen/kotlin_contract_test.go new file mode 100644 index 0000000..6531ff1 --- /dev/null +++ b/internal/codegen/kotlin_contract_test.go @@ -0,0 +1,273 @@ +package codegen + +import ( + "strings" + "testing" + + "google.golang.org/protobuf/compiler/protogen" +) + +func TestKotlinContract_PublicAPIAndRegistrationShape(t *testing.T) { + generated := renderBasicKotlinFixture(t) + + wantSnippets := []string{ + "interface ExampleAPIToolHandler", + "suspend fun createReport(ctx: ClientConnection, request: CreateReportRequest): CreateReportResponse", + "fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespace: String? = null)", + "installMcpHandlers(server)", + } + assertKotlinContains(t, generated, wantSnippets...) + + notWantSnippets := []string{ + "server." + "addTool(", + "addTool" + "(name =", + "Dynamic" + "Message", + "Descr" + "iptor", + } + assertKotlinOmits(t, generated, notWantSnippets...) +} + +func TestKotlinContract_LowLevelServerContractShape(t *testing.T) { + generated := renderBasicKotlinFixture(t) + + wantSnippets := []string{ + "private class ServerToolRegistry", + "private suspend fun dispatchToolCall", + "ListToolsRequest", + "CallToolRequest", + "private suspend fun listRegisteredTools", + "session.setRequestHandler(Method.Defined.ToolsList)", + "session.setRequestHandler(Method.Defined.ToolsCall)", + } + assertKotlinContains(t, generated, wantSnippets...) +} + +func TestKotlinContract_JavaPackageAndWrapperImports(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/shared.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.shared.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/shared;sharedv1";`, + `option java_package = "com.example.shared";`, + `option java_outer_classname = "SharedTypes";`, + `message SharedRequest {}`, + `message SharedResponse {}`, + ``, + }, "\n"), + "test/v1/shared_multi.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.sharedmulti.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/sharedmulti;sharedmultiv1";`, + `option java_package = "com.example.sharedmulti";`, + `option java_multiple_files = true;`, + `message MultiRequest {}`, + `message MultiResponse {}`, + ``, + }, "\n"), + "test/v1/shared_default_outer.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.defaultouter.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/defaultouter;defaultouterv1";`, + `option java_package = "com.example.defaultouter";`, + `message DefaultRequest {}`, + `message DefaultResponse {}`, + ``, + }, "\n"), + "test/v1/service.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.service.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/service;servicev1";`, + `option java_package = "com.example.service";`, + `option java_multiple_files = true;`, + `import "test/v1/shared.proto";`, + `import "test/v1/shared_multi.proto";`, + `import "test/v1/shared_default_outer.proto";`, + `service CrossFileAPI {`, + ` rpc UseShared(test.shared.v1.SharedRequest) returns (test.shared.v1.SharedResponse);`, + ` rpc UseMulti(test.sharedmulti.v1.MultiRequest) returns (test.sharedmulti.v1.MultiResponse);`, + ` rpc UseDefault(test.defaultouter.v1.DefaultRequest) returns (test.defaultouter.v1.DefaultResponse);`, + `}`, + ``, + }, "\n"), + }, "test/v1/service.proto") + + generated := renderKotlinFileFromPlugin(t, plugin, "test/v1/service.proto") + wantSnippets := []string{ + "package com.example.service", + "import com.example.shared.SharedTypes", + "import com.example.sharedmulti.MultiRequest", + "import com.example.sharedmulti.MultiResponse", + "import com.example.defaultouter.SharedDefaultOuterOuterClass", + "suspend fun useShared(ctx: ClientConnection, request: SharedTypes.SharedRequest): SharedTypes.SharedResponse", + "suspend fun useMulti(ctx: ClientConnection, request: MultiRequest): MultiResponse", + "suspend fun useDefault(ctx: ClientConnection, request: SharedDefaultOuterOuterClass.DefaultRequest): SharedDefaultOuterOuterClass.DefaultResponse", + } + assertKotlinContains(t, generated, wantSnippets...) +} + +func TestKotlinContract_RawSchemaProjectionAndValidation(t *testing.T) { + generated := renderBasicKotlinFixture(t) + + wantSnippets := []string{ + "private fun buildListTool(tool: RegisteredTool): Tool", + "private fun loadSchema(rawSchemaJson: String): JsonObject", + "validateJson(tool.inputSchemaJson, arguments)", + "parseProtoJson(arguments.toString(), tool.requestBuilder())", + "marshalProtoJson(responseMessage)", + "validateJson(tool.outputSchemaJson, payload)", + "CallToolResult(content = listOf(TextContent(text = textPayload)), structuredContent = payload)", + } + assertKotlinContains(t, generated, wantSnippets...) +} + +func TestKotlinContract_AnnotationsIconsAndThemeValidation(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/metadata.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.metadata.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/metadata;metadatav1";`, + `option java_package = "com.example.metadata";`, + `option java_multiple_files = true;`, + `import "mcp/options/v1/options.proto";`, + `message MetadataRequest {}`, + `message MetadataResponse {}`, + `service MetadataAPI {`, + ` rpc OptionalTask(MetadataRequest) returns (MetadataResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` annotations: { read_only_hint: true open_world_hint: false }`, + ` icons: [{ src: "https://example.com/light.svg" mime_type: "image/svg+xml" sizes: "64x64" theme: "light" }]`, + ` execution: { task_support: TASK_SUPPORT_OPTIONAL }`, + ` };`, + ` }`, + ` rpc RequiredTask(MetadataRequest) returns (MetadataResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` icons: [{ src: "https://example.com/dark.svg" mime_type: "image/svg+xml" sizes: "64x64" theme: "dark" }]`, + ` execution: { task_support: TASK_SUPPORT_REQUIRED }`, + ` };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/metadata.proto") + + generated := renderKotlinFileFromPlugin(t, plugin, "test/v1/metadata.proto") + wantSnippets := []string{ + "ToolAnnotations(readOnlyHint = true, openWorldHint = false)", + "ToolExecution(taskSupport = TaskSupport.Optional)", + "ToolExecution(taskSupport = TaskSupport.Required)", + "Icon.Theme.Light", + "Icon.Theme.Dark", + `theme = iconThemeOrError("light")`, + `theme = iconThemeOrError("dark")`, + } + assertKotlinContains(t, generated, wantSnippets...) + + invalidPlugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/invalid_theme.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.invalidtheme.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/invalidtheme;invalidthemev1";`, + `option java_package = "com.example.invalidtheme";`, + `option java_multiple_files = true;`, + `import "mcp/options/v1/options.proto";`, + `message InvalidThemeRequest {}`, + `message InvalidThemeResponse {}`, + `service InvalidThemeAPI {`, + ` rpc BadIcon(InvalidThemeRequest) returns (InvalidThemeResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` icons: [{ src: "https://example.com/icon.svg" theme: "solarized" }]`, + ` };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/invalid_theme.proto") + invalidFile := invalidPlugin.FilesByPath["test/v1/invalid_theme.proto"] + if invalidFile == nil { + t.Fatal("invalid theme proto file not found in plugin") + } + shared, err := CollectFileModel(invalidFile, Options{Language: LanguageKotlin}) + if err != nil { + t.Fatalf("CollectFileModel invalid theme: %v", err) + } + jvm, err := CollectJVMFileModel(invalidFile, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel invalid theme: %v", err) + } + err = renderKotlinFile(invalidPlugin, jvm) + if err == nil { + t.Fatal("renderKotlinFile unexpectedly succeeded for invalid icon theme") + } + if !strings.Contains(err.Error(), `unsupported Kotlin icon theme "solarized"`) { + t.Fatalf("renderKotlinFile error = %v, want invalid theme rejection", err) + } + if got := len(invalidPlugin.Response().GetFile()); got != 0 { + t.Fatalf("invalid theme emitted %d files before failing, want 0", got) + } +} + +func renderBasicKotlinFixture(t *testing.T) string { + t.Helper() + + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/example.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.example.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/example;examplev1";`, + `option java_package = "com.example.contract";`, + `option java_multiple_files = true;`, + `import "mcp/options/v1/options.proto";`, + `message CreateReportRequest { string title = 1; }`, + `message CreateReportResponse { string report_id = 1; }`, + `service ExampleAPI {`, + ` option (mcp.options.v1.service) = { namespace: "example" };`, + ` rpc CreateReport(CreateReportRequest) returns (CreateReportResponse) {`, + ` option (mcp.options.v1.method) = { title: "Create report" };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/example.proto") + + return renderKotlinFileFromPlugin(t, plugin, "test/v1/example.proto") +} + +func renderKotlinFileFromPlugin(t *testing.T, plugin *protogen.Plugin, protoPath string) string { + t.Helper() + + file := plugin.FilesByPath[protoPath] + if file == nil { + t.Fatalf("proto file %q not found in plugin", protoPath) + } + shared, err := CollectFileModel(file, Options{Language: LanguageKotlin}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + jvm, err := CollectJVMFileModel(file, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel: %v", err) + } + if err := renderKotlinFile(plugin, jvm); err != nil { + t.Fatalf("renderKotlinFile: %v", err) + } + + return string(generatedFileContent(t, plugin, strings.TrimSuffix(protoPath, ".proto")+"_mcp.kt")) +} + +func assertKotlinContains(t *testing.T, generated string, snippets ...string) { + t.Helper() + for _, snippet := range snippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated Kotlin missing snippet %q\n%s", snippet, generated) + } + } +} + +func assertKotlinOmits(t *testing.T, generated string, snippets ...string) { + t.Helper() + for _, snippet := range snippets { + if strings.Contains(generated, snippet) { + t.Fatalf("generated Kotlin must not contain snippet %q\n%s", snippet, generated) + } + } +} diff --git a/internal/codegen/render_kotlin.go b/internal/codegen/render_kotlin.go new file mode 100644 index 0000000..b0c8c54 --- /dev/null +++ b/internal/codegen/render_kotlin.go @@ -0,0 +1,472 @@ +package codegen + +import ( + "fmt" + "sort" + "strings" + + mcpoptionsv1 "github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1" + "google.golang.org/protobuf/compiler/protogen" +) + +func renderKotlinFile(plugin *protogen.Plugin, model JVMFileModel) error { + if model.Language != LanguageKotlin { + return fmt.Errorf("kotlin renderer requires lang=kotlin, got %q", model.Language) + } + if err := validateKotlinMetadata(model); err != nil { + return err + } + + info, err := newKotlinRenderInfo(plugin, model) + if err != nil { + return err + } + + filePackage, err := resolveJVMFilePackage(info.file) + if err != nil { + return err + } + + imports := map[string]bool{} + for _, service := range model.Services { + for _, method := range service.Methods { + if _, err := info.kotlinMessageTypeExpr(method.Input, imports, filePackage.Package); err != nil { + return err + } + if _, err := info.kotlinMessageTypeExpr(method.Output, imports, filePackage.Package); err != nil { + return err + } + } + } + + filename := model.GeneratedFilenamePrefix + "_mcp.kt" + generated := plugin.NewGeneratedFile(filename, "") + + generated.P("// Code generated by protoc-gen-mcp. DO NOT EDIT.") + generated.P("// source: ", model.ProtoPath) + generated.P() + if filePackage.Package != "" { + generated.P("package ", filePackage.Package) + generated.P() + } + + kotlinImports := []string{ + "com.google.protobuf.Message", + "com.google.protobuf.util.JsonFormat", + "io.modelcontextprotocol.kotlin.sdk.server.ClientConnection", + "io.modelcontextprotocol.kotlin.sdk.server.Server", + "io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest", + "io.modelcontextprotocol.kotlin.sdk.types.CallToolResult", + "io.modelcontextprotocol.kotlin.sdk.types.Icon", + "io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest", + "io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult", + "io.modelcontextprotocol.kotlin.sdk.types.McpException", + "io.modelcontextprotocol.kotlin.sdk.types.Method", + "io.modelcontextprotocol.kotlin.sdk.types.RPCError", + "io.modelcontextprotocol.kotlin.sdk.types.TaskSupport", + "io.modelcontextprotocol.kotlin.sdk.types.TextContent", + "io.modelcontextprotocol.kotlin.sdk.types.Tool", + "io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations", + "io.modelcontextprotocol.kotlin.sdk.types.ToolExecution", + "io.modelcontextprotocol.kotlin.sdk.types.ToolSchema", + "kotlinx.serialization.json.Json", + "kotlinx.serialization.json.JsonElement", + "kotlinx.serialization.json.JsonObject", + "kotlinx.serialization.json.buildJsonObject", + "kotlinx.serialization.json.jsonArray", + "kotlinx.serialization.json.jsonObject", + "kotlinx.serialization.json.jsonPrimitive", + "java.util.WeakHashMap", + } + for importPath := range imports { + kotlinImports = append(kotlinImports, importPath) + } + sort.Strings(kotlinImports) + for _, importPath := range kotlinImports { + generated.P("import ", importPath) + } + generated.P() + + renderKotlinRuntime(generated) + + for serviceIdx, service := range model.Services { + generated.P("interface ", service.HandlerName, " {") + for _, method := range service.Methods { + inputType, err := info.kotlinMessageTypeExpr(method.Input, imports, filePackage.Package) + if err != nil { + return err + } + outputType, err := info.kotlinMessageTypeExpr(method.Output, imports, filePackage.Package) + if err != nil { + return err + } + generated.P(" suspend fun ", method.MethodName, "(ctx: ClientConnection, request: ", inputType, "): ", outputType) + } + generated.P("}") + generated.P() + + generated.P("fun ", service.RegisterName, "(server: Server, impl: ", service.HandlerName, ", namespace: String? = null) {") + generated.P(" val registry = installMcpHandlers(server)") + generated.P(" val resolvedNamespace = normalizeNamespace(namespace, ", quote(service.Namespace), ")") + for _, method := range service.Methods { + inputType, err := info.kotlinMessageTypeExpr(method.Input, imports, filePackage.Package) + if err != nil { + return err + } + outputType, err := info.kotlinMessageTypeExpr(method.Output, imports, filePackage.Package) + if err != nil { + return err + } + generated.P(" registry.addTool(") + generated.P(" RegisteredTool(") + generated.P(" name = toolName(resolvedNamespace, ", quote(method.ToolName), "),") + generated.P(" title = ", kotlinNullableString(method.Title), ",") + generated.P(" description = ", kotlinNullableString(method.Description), ",") + generated.P(" inputSchemaJson = ", method.SchemaConst, "_INPUT_SCHEMA_JSON,") + generated.P(" outputSchemaJson = ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON,") + generated.P(" requestBuilder = { ", inputType, ".newBuilder() },") + generated.P(" handler = { ctx, request -> impl.", method.MethodName, "(ctx, request as ", inputType, ") as Message },") + generated.P(" annotations = ", kotlinAnnotations(method.Annotations), ",") + icons, err := kotlinIcons(method.Icons) + if err != nil { + return err + } + generated.P(" icons = ", icons, ",") + generated.P(" execution = ", kotlinTaskSupport(method.TaskSupport), ",") + generated.P(" ),") + generated.P(" )") + _ = outputType + } + generated.P("}") + generated.P() + + for methodIdx, method := range service.Methods { + generated.P("private const val ", method.SchemaConst, "_INPUT_SCHEMA_JSON = ", quote(method.InputSchemaJSON)) + generated.P() + generated.P("private const val ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON = ", quote(method.OutputSchemaJSON)) + if methodIdx < len(service.Methods)-1 || serviceIdx < len(model.Services)-1 { + generated.P() + } + } + } + + return nil +} + +type kotlinRenderInfo struct { + file *protogen.File + messages map[string]*protogen.Message +} + +func newKotlinRenderInfo(plugin *protogen.Plugin, model JVMFileModel) (kotlinRenderInfo, error) { + file := plugin.FilesByPath[model.ProtoPath] + if file == nil { + return kotlinRenderInfo{}, fmt.Errorf("generated file %q not found in plugin", model.ProtoPath) + } + + info := kotlinRenderInfo{ + file: file, + messages: map[string]*protogen.Message{}, + } + for _, current := range plugin.Files { + indexKotlinMessages(info.messages, current.Messages) + } + + return info, nil +} + +func indexKotlinMessages(index map[string]*protogen.Message, messages []*protogen.Message) { + for _, message := range messages { + index[string(message.Desc.FullName())] = message + indexKotlinMessages(index, message.Messages) + } +} + +func (k kotlinRenderInfo) kotlinMessageTypeExpr(ref JVMTypeRef, imports map[string]bool, currentPackage string) (string, error) { + message := k.messages[ref.ProtoFullName] + if message == nil { + return "", fmt.Errorf("message %q not found during Kotlin render", ref.ProtoFullName) + } + resolved, err := resolveJVMMessageTypeRef(message, k.file.Desc.Path()) + if err != nil { + return "", err + } + if resolved.ImportPath != "" && kotlinImportPackage(resolved.ImportPath) != currentPackage { + imports[resolved.ImportPath] = true + } + return resolved.Expr, nil +} + +func renderKotlinRuntime(generated *protogen.GeneratedFile) { + generated.P("private data class RegisteredTool(") + generated.P(" val name: String,") + generated.P(" val title: String?,") + generated.P(" val description: String?,") + generated.P(" val inputSchemaJson: String,") + generated.P(" val outputSchemaJson: String,") + generated.P(" val requestBuilder: () -> Message.Builder,") + generated.P(" val handler: suspend (ClientConnection, Message) -> Message,") + generated.P(" val annotations: ToolAnnotations?,") + generated.P(" val icons: List,") + generated.P(" val execution: ToolExecution?,") + generated.P(")") + generated.P() + generated.P("private class ServerToolRegistry {") + generated.P(" private val toolsByName = linkedMapOf()") + generated.P(" private val sessionsWithHandlers = mutableSetOf()") + generated.P(" var handlersInstalled: Boolean = false") + generated.P() + generated.P(" fun addTool(tool: RegisteredTool) {") + generated.P(" require(!toolsByName.containsKey(tool.name)) { \"duplicate tool registration: ${tool.name}\" }") + generated.P(" toolsByName[tool.name] = tool") + generated.P(" }") + generated.P() + generated.P(" fun allTools(): List = toolsByName.values.toList()") + generated.P() + generated.P(" fun tool(name: String): RegisteredTool? = toolsByName[name]") + generated.P() + generated.P(" fun markSessionInstalled(sessionId: String): Boolean = sessionsWithHandlers.add(sessionId)") + generated.P("}") + generated.P() + generated.P("private val serverRegistries = WeakHashMap()") + generated.P() + generated.P("private fun installMcpHandlers(server: Server): ServerToolRegistry {") + generated.P(" val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() }") + generated.P(" if (registry.handlersInstalled) {") + generated.P(" return registry") + generated.P(" }") + generated.P(" require(server.tools.isEmpty()) { \"protoc-gen-mcp generated handlers cannot compose with pre-existing SDK tool registrations\" }") + generated.P(" installSessionHandlers(server, registry)") + generated.P(" server.onConnect { installSessionHandlers(server, registry) }") + generated.P(" registry.handlersInstalled = true") + generated.P(" return registry") + generated.P("}") + generated.P() + generated.P("private fun installSessionHandlers(server: Server, registry: ServerToolRegistry) {") + generated.P(" for (session in server.sessions.values) {") + generated.P(" if (!registry.markSessionInstalled(session.sessionId)) {") + generated.P(" continue") + generated.P(" }") + generated.P(" session.setRequestHandler(Method.Defined.ToolsList) { request: ListToolsRequest, _ ->") + generated.P(" listRegisteredTools(registry, request)") + generated.P(" }") + generated.P(" session.setRequestHandler(Method.Defined.ToolsCall) { request: CallToolRequest, _ ->") + generated.P(" dispatchToolCall(registry, session.clientConnection, request)") + generated.P(" }") + generated.P(" }") + generated.P("}") + generated.P() + generated.P("private suspend fun listRegisteredTools(registry: ServerToolRegistry, request: ListToolsRequest): ListToolsResult {") + generated.P(" return ListToolsResult(tools = registry.allTools().map { buildListTool(it) }, nextCursor = null)") + generated.P("}") + generated.P() + generated.P("private suspend fun dispatchToolCall(registry: ServerToolRegistry, ctx: ClientConnection, request: CallToolRequest): CallToolResult {") + generated.P(" val tool = registry.tool(request.params.name) ?: invalidParams(request.params.name, \"unknown tool\")") + generated.P(" val arguments = request.params.arguments ?: buildJsonObject {}") + generated.P(" validateJson(tool.inputSchemaJson, arguments)") + generated.P(" val requestMessage = parseProtoJson(arguments.toString(), tool.requestBuilder())") + generated.P(" val responseMessage = try {") + generated.P(" tool.handler(ctx, requestMessage)") + generated.P(" } catch (error: Exception) {") + generated.P(" return CallToolResult(content = listOf(TextContent(text = error.message ?: error.toString())), isError = true)") + generated.P(" }") + generated.P(" val payload = marshalProtoJson(responseMessage)") + generated.P(" validateJson(tool.outputSchemaJson, payload)") + generated.P(" val textPayload = payload.toString()") + generated.P(" return CallToolResult(content = listOf(TextContent(text = textPayload)), structuredContent = payload)") + generated.P("}") + generated.P() + generated.P("private fun buildListTool(tool: RegisteredTool): Tool {") + generated.P(" return Tool(") + generated.P(" name = tool.name,") + generated.P(" title = tool.title,") + generated.P(" description = tool.description,") + generated.P(" inputSchema = projectToolSchema(tool.inputSchemaJson),") + generated.P(" outputSchema = projectToolSchema(tool.outputSchemaJson),") + generated.P(" annotations = tool.annotations,") + generated.P(" icons = tool.icons.ifEmpty { null },") + generated.P(" execution = tool.execution,") + generated.P(" )") + generated.P("}") + generated.P() + generated.P("private fun loadSchema(rawSchemaJson: String): JsonObject = Json.parseToJsonElement(rawSchemaJson).jsonObject") + generated.P() + generated.P("private fun projectToolSchema(rawSchemaJson: String): ToolSchema {") + generated.P(" val schema = loadSchema(rawSchemaJson)") + generated.P(" val properties = schema[\"properties\"]?.jsonObject ?: buildJsonObject {}") + generated.P(" val required = schema[\"required\"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()") + generated.P(" return ToolSchema(properties = properties, required = required)") + generated.P("}") + generated.P() + generated.P("private fun validateJson(rawSchemaJson: String, payload: JsonElement) {") + generated.P(" val schema = loadSchema(rawSchemaJson)") + generated.P(" val required = schema[\"required\"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()") + generated.P(" if (payload !is JsonObject) {") + generated.P(" invalidParams(\"\", \"payload must be a JSON object\")") + generated.P(" }") + generated.P(" val missing = required.filterNot { payload.containsKey(it) }") + generated.P(" if (missing.isNotEmpty()) {") + generated.P(" invalidParams(\"\", \"missing required fields: ${missing.joinToString(\", \")}\")") + generated.P(" }") + generated.P("}") + generated.P() + generated.P("private fun parseProtoJson(payload: String, builder: Message.Builder): Message {") + generated.P(" try {") + generated.P(" JsonFormat.parser().merge(payload, builder)") + generated.P(" return builder.build()") + generated.P(" } catch (error: Exception) {") + generated.P(" invalidParams(\"\", error.message ?: error.toString())") + generated.P(" }") + generated.P("}") + generated.P() + generated.P("private fun marshalProtoJson(message: Message): JsonObject {") + generated.P(" val jsonPayload = JsonFormat.printer().includingDefaultValueFields().print(message)") + generated.P(" return Json.parseToJsonElement(jsonPayload).jsonObject") + generated.P("}") + generated.P() + generated.P("private fun invalidParams(toolName: String, reason: String): Nothing {") + generated.P(" throw McpException(code = RPCError.ErrorCode.INVALID_PARAMS, message = \"invalid arguments for tool '$toolName': $reason\")") + generated.P("}") + generated.P() + generated.P("private fun toolExecutionOrNull(taskSupport: String): ToolExecution? = when (taskSupport) {") + generated.P(" \"optional\" -> ToolExecution(taskSupport = TaskSupport.Optional)") + generated.P(" \"required\" -> ToolExecution(taskSupport = TaskSupport.Required)") + generated.P(" else -> null") + generated.P("}") + generated.P() + generated.P("private fun iconThemeOrError(rawTheme: String?): Icon.Theme? = when (rawTheme) {") + generated.P(" null, \"\" -> null") + generated.P(" \"light\" -> Icon.Theme.Light") + generated.P(" \"dark\" -> Icon.Theme.Dark") + generated.P(" else -> throw IllegalArgumentException(\"unsupported icon theme: $rawTheme\")") + generated.P("}") + generated.P() + generated.P("private fun normalizeToolSegment(segment: String?): String {") + generated.P(" return segment") + generated.P(" ?.trim()") + generated.P(" ?.replace('.', '_')") + generated.P(" ?.split('_')") + generated.P(" ?.filter { it.isNotBlank() }") + generated.P(" ?.joinToString(\"_\")") + generated.P(" ?: \"\"") + generated.P("}") + generated.P() + generated.P("private fun normalizeNamespace(namespace: String?, defaultNamespace: String): String = normalizeToolSegment(namespace ?: defaultNamespace)") + generated.P() + generated.P("private fun toolName(namespace: String, methodName: String): String {") + generated.P(" val normalizedNamespace = normalizeToolSegment(namespace)") + generated.P(" val normalizedMethodName = normalizeToolSegment(methodName)") + generated.P(" return when {") + generated.P(" normalizedNamespace.isEmpty() -> normalizedMethodName") + generated.P(" normalizedMethodName.isEmpty() -> normalizedNamespace") + generated.P(" else -> \"${normalizedNamespace}_${normalizedMethodName}\"") + generated.P(" }") + generated.P("}") + generated.P() +} + +func validateKotlinMetadata(model JVMFileModel) error { + for _, service := range model.Services { + for _, icon := range service.Icons { + if err := validateKotlinIconTheme(icon); err != nil { + return err + } + } + for _, method := range service.Methods { + for _, icon := range method.Icons { + if err := validateKotlinIconTheme(icon); err != nil { + return err + } + } + } + } + return nil +} + +func validateKotlinIconTheme(icon *mcpoptionsv1.Icon) error { + switch icon.GetTheme() { + case "", "light", "dark": + return nil + default: + return fmt.Errorf("unsupported Kotlin icon theme %q", icon.GetTheme()) + } +} + +func kotlinAnnotations(ann *mcpoptionsv1.ToolAnnotations) string { + if ann == nil { + return "null" + } + + var fields []string + if ann.ReadOnlyHint { + fields = append(fields, fmt.Sprintf("readOnlyHint = %t", ann.ReadOnlyHint)) + } + if ann.DestructiveHint != nil { + fields = append(fields, fmt.Sprintf("destructiveHint = %t", ann.GetDestructiveHint())) + } + if ann.IdempotentHint { + fields = append(fields, fmt.Sprintf("idempotentHint = %t", ann.IdempotentHint)) + } + if ann.OpenWorldHint != nil { + fields = append(fields, fmt.Sprintf("openWorldHint = %t", ann.GetOpenWorldHint())) + } + if ann.Title != "" { + fields = append(fields, "title = "+quote(ann.Title)) + } + return "ToolAnnotations(" + strings.Join(fields, ", ") + ")" +} + +func kotlinIcons(icons []*mcpoptionsv1.Icon) (string, error) { + if len(icons) == 0 { + return "emptyList()", nil + } + + items := make([]string, 0, len(icons)) + for _, icon := range icons { + if err := validateKotlinIconTheme(icon); err != nil { + return "", err + } + sizes := "emptyList()" + if len(icon.GetSizes()) > 0 { + quotedSizes := make([]string, 0, len(icon.GetSizes())) + for _, size := range icon.GetSizes() { + quotedSizes = append(quotedSizes, quote(size)) + } + sizes = "listOf(" + strings.Join(quotedSizes, ", ") + ")" + } + items = append(items, fmt.Sprintf( + "Icon(src = %s, mimeType = %s, sizes = %s, theme = iconThemeOrError(%s))", + quote(icon.GetSrc()), + kotlinNullableString(icon.GetMimeType()), + sizes, + kotlinNullableString(icon.GetTheme()), + )) + } + return "listOf(" + strings.Join(items, ", ") + ")", nil +} + +func kotlinTaskSupport(taskSupport mcpoptionsv1.TaskSupport) string { + switch taskSupport { + case mcpoptionsv1.TaskSupport_TASK_SUPPORT_OPTIONAL: + return `toolExecutionOrNull("optional")` + case mcpoptionsv1.TaskSupport_TASK_SUPPORT_REQUIRED: + return `toolExecutionOrNull("required")` + default: + return `toolExecutionOrNull("none")` + } +} + +func kotlinNullableString(value string) string { + if value == "" { + return "null" + } + return quote(value) +} + +func kotlinImportPackage(importPath string) string { + idx := strings.LastIndex(importPath, ".") + if idx <= 0 { + return "" + } + return importPath[:idx] +} From 039d02288a2dca6200bf346d15fc6f1f42d2e523 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 12:31:00 +0300 Subject: [PATCH 14/74] feat(02-03): wire Kotlin generation output --- AGENTS.md | 39 +++- internal/codegen/generator.go | 44 +++- internal/codegen/generator_test.go | 78 +++++-- internal/codegen/render_kotlin.go | 15 +- testdata/golden/example_mcp.kt.golden | 312 ++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 35 deletions(-) create mode 100644 testdata/golden/example_mcp.kt.golden diff --git a/AGENTS.md b/AGENTS.md index 13c4bd3..db46b8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,10 @@ ## Scope -This repository implements a protobuf-first MCP generator and runtime for Go -and Python MCP server bindings. The MVP is intentionally narrow and must stay -decision-consistent with the current architecture unless explicitly revised. +This repository implements a protobuf-first MCP generator and runtime for Go, +Python, and Kotlin MCP server bindings. The MVP is intentionally narrow and +must stay decision-consistent with the current architecture unless explicitly +revised. ## Stack @@ -15,6 +16,8 @@ decision-consistent with the current architecture unless explicitly revised. - `google.protobuf` for Python generated modules and ProtoJSON conversion - `github.com/modelcontextprotocol/go-sdk/mcp` as the MCP runtime - `mcp>=1.27,<2` as the official Python MCP SDK target +- `io.modelcontextprotocol:kotlin-sdk-server` as the official Kotlin MCP SDK + target - `github.com/google/jsonschema-go/jsonschema` for JSON Schema parsing and validation - `github.com/bufbuild/protocompile` for in-process descriptor compilation in @@ -43,7 +46,10 @@ decision-consistent with the current architecture unless explicitly revised. - `mcp/options/v1/options.proto`: custom protobuf options for MCP metadata - `internal/codegen`: code generation logic - `internal/codegen/jvm_*.go`: shared JVM semantic model, naming, and collector - foundation used by future Kotlin/Java renderers + foundation used by the Kotlin renderer and future Java renderer +- `internal/codegen/render_kotlin.go`: self-contained Kotlin sidecar renderer +- `internal/codegen/kotlin_contract_test.go`: Kotlin public API, SDK wiring, + schema-path, and JVM import contract tests - `internal/examplemcp`: reusable example MCP server wiring and stdio smoke test - `internal/schema`: protobuf descriptor to JSON Schema conversion - `internal/testproto`: protobuf fixtures and generated code used in repository tests @@ -58,7 +64,8 @@ decision-consistent with the current architecture unless explicitly revised. - Python generation emits package `__init__.py` files next to generated `*_mcp.py` modules so generated directories can be imported as Python packages -- `testdata/golden`: golden snapshots for generated `*.mcp.go` files +- `testdata/golden`: golden snapshots for generated Go, Python, and Kotlin + binding files - `testdata/unsupported`: negative fixtures for fail-fast generator coverage ## MVP Rules @@ -102,6 +109,9 @@ decision-consistent with the current architecture unless explicitly revised. - Generated Python modules expose dataclasses, `UNSET`, and explicit `oneof` wrapper variants from `*_mcp.py`; user handler code should not depend on `*_pb2.py` +- Generated Kotlin files expose `ToolHandler` +- Generated Kotlin files expose + `registerTools(server: Server, impl: ToolHandler, namespace: String? = null)` - Runtime exposes only the minimal registration options used by generated code - Generated MCP tool names must not contain dots; namespace prefixes and method names are joined with underscores, and any dots in configured segments are @@ -117,10 +127,16 @@ decision-consistent with the current architecture unless explicitly revised. - typed plugin option parsing for `lang=go|python|kotlin|java` and `python_runtime=google.protobuf|betterproto|grpclib` - shared JVM foundation for `lang=kotlin` and `lang=java`: parser and - generator dispatch accept both targets, collect SDK-neutral `internal/codegen/jvm_*.go` - models, preserve existing `FileModel` schema JSON/annotations/icons/type - semantics, and emit no JVM files until Kotlin/Java renderers are implemented - in later phases + generator dispatch accept both targets, collect SDK-neutral + `internal/codegen/jvm_*.go` models, preserve existing `FileModel` schema + JSON/annotations/icons/type semantics, and keep Java collect-only until its + renderer phase + - generated self-contained Kotlin `*_mcp.kt` bindings for `lang=kotlin`, + targeting `io.modelcontextprotocol:kotlin-sdk-server`, including + protobuf-typed handler interfaces, namespace-aware + `registerTools(...)`, generated low-level `tools/list` and + `tools/call` helper wiring, raw schema JSON constants, ProtoJSON + parse/marshal helpers, annotations, icons, and task-support mapping - single-source custom generator option handling through `protogen.Options.ParamFunc`, with fail-fast rejection of unknown `protoc-gen-mcp` params @@ -215,7 +231,10 @@ decision-consistent with the current architecture unless explicitly revised. proto3 `optional` scalar/enum fields - Verified: - `easyp` lint and generation flows for `mcp` and `internal/testproto` - - `go test ./internal/codegen -count=1` for generator, Go/Python, and shared JVM foundation coverage + - `go test ./internal/codegen -count=1` for generator, Go/Python, Kotlin, + and shared JVM foundation coverage + - `go test ./internal/codegen -run 'TestGenerateKotlinExampleGolden|TestKotlinContract_.*' -count=1` + for Kotlin golden output and focused Kotlin renderer contracts - `go test ./...` - stdio smoke tests via `internal/examplemcp/stdio_test.go` - Python stdio integration coverage for the shared server: diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 18dce1f..801d614 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -41,7 +41,22 @@ func Generate(plugin *protogen.Plugin, opts Options) error { } switch opts.Language { - case LanguageKotlin, LanguageJava: + case LanguageKotlin: + models, orderedFiles, err := collectKotlinModels(plugin, opts) + if err != nil { + return err + } + for _, file := range orderedFiles { + model := models[file.Desc.Path()] + if len(model.Services) == 0 { + continue + } + if err := renderKotlinFile(plugin, model); err != nil { + return err + } + } + return nil + case LanguageJava: for _, file := range plugin.Files { if !file.Generate { continue @@ -89,6 +104,33 @@ func Generate(plugin *protogen.Plugin, opts Options) error { return nil } +func collectKotlinModels(plugin *protogen.Plugin, opts Options) (map[string]JVMFileModel, []*protogen.File, error) { + models := make(map[string]JVMFileModel) + orderedFiles := make([]*protogen.File, 0, len(plugin.Files)) + + for _, file := range plugin.Files { + if !file.Generate { + continue + } + + model, err := CollectFileModel(file, opts) + if err != nil { + return nil, nil, err + } + jvmModel, err := CollectJVMFileModel(file, model) + if err != nil { + return nil, nil, err + } + if err := validateKotlinMetadata(jvmModel); err != nil { + return nil, nil, err + } + models[file.Desc.Path()] = jvmModel + orderedFiles = append(orderedFiles, file) + } + + return models, orderedFiles, nil +} + func collectPythonModels(plugin *protogen.Plugin, opts Options) (map[string]FileModel, []*protogen.File, error) { models := make(map[string]FileModel) orderedFiles := make([]*protogen.File, 0, len(plugin.Files)) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 956da3c..ea26224 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -41,31 +41,52 @@ func TestGeneratePythonExampleGolden(t *testing.T) { } } -func TestGenerate_JVMTargetsCollectWithoutOutput(t *testing.T) { - tests := []struct { - name string - language Language - }{ - { - name: "kotlin", - language: LanguageKotlin, - }, - { - name: "java", - language: LanguageJava, - }, +func TestGenerateKotlinExampleGolden(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: LanguageKotlin}); err != nil { + t.Fatalf("Generate: %v", err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - plugin := newExampleProtogenPlugin(t) - if err := Generate(plugin, Options{Language: tt.language}); err != nil { - t.Fatalf("Generate: %v", err) - } - if len(plugin.Response().GetFile()) != 0 { - t.Fatalf("JVM foundation dispatch emitted %d files, want 0", len(plugin.Response().GetFile())) - } - }) + got := generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp.kt") + want := readExampleKotlinGolden(t, repoRoot(t)) + if !bytes.Equal(got, want) { + t.Fatalf("fresh Kotlin renderer output differs from golden:\n%s", diffBytes(want, got)) + } +} + +func TestGenerate_JavaTargetCollectsWithoutOutput(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: LanguageJava}); err != nil { + t.Fatalf("Generate: %v", err) + } + if len(plugin.Response().GetFile()) != 0 { + t.Fatalf("Java foundation dispatch emitted %d files, want 0", len(plugin.Response().GetFile())) + } +} + +func TestGenerate_KotlinTargetEmitsOutput(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: LanguageKotlin}); err != nil { + t.Fatalf("Generate: %v", err) + } + + generated := string(generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp.kt")) + wantSnippets := []string{ + "interface ExampleAPIToolHandler", + "fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespace: String? = null)", + "installMcpHandlers(server)", + "private class ServerToolRegistry", + "private suspend fun dispatchToolCall", + "ListToolsRequest", + "CallToolRequest", + } + for _, snippet := range wantSnippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated Kotlin missing snippet %q\n%s", snippet, generated) + } + } + if strings.Contains(generated, "server."+"addTool(") { + t.Fatalf("generated Kotlin must not use high-level server tool registration\n%s", generated) } } @@ -683,6 +704,17 @@ func readExamplePythonGolden(t *testing.T, root string) []byte { return want } +func readExampleKotlinGolden(t *testing.T, root string) []byte { + t.Helper() + + wantPath := filepath.Join(root, "testdata/golden/example_mcp.kt.golden") + want, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("read golden file %q: %v", wantPath, err) + } + return want +} + func generatedFileContent(t *testing.T, plugin *protogen.Plugin, path string) []byte { t.Helper() diff --git a/internal/codegen/render_kotlin.go b/internal/codegen/render_kotlin.go index b0c8c54..b424754 100644 --- a/internal/codegen/render_kotlin.go +++ b/internal/codegen/render_kotlin.go @@ -283,12 +283,25 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P(" description = tool.description,") generated.P(" inputSchema = projectToolSchema(tool.inputSchemaJson),") generated.P(" outputSchema = projectToolSchema(tool.outputSchemaJson),") - generated.P(" annotations = tool.annotations,") + generated.P(" annotations = copyToolAnnotations(tool.annotations),") generated.P(" icons = tool.icons.ifEmpty { null },") generated.P(" execution = tool.execution,") generated.P(" )") generated.P("}") generated.P() + generated.P("private fun copyToolAnnotations(annotations: ToolAnnotations?): ToolAnnotations? {") + generated.P(" if (annotations == null) {") + generated.P(" return null") + generated.P(" }") + generated.P(" return ToolAnnotations(") + generated.P(" title = annotations.title,") + generated.P(" readOnlyHint = annotations.readOnlyHint,") + generated.P(" destructiveHint = annotations.destructiveHint,") + generated.P(" idempotentHint = annotations.idempotentHint,") + generated.P(" openWorldHint = annotations.openWorldHint,") + generated.P(" )") + generated.P("}") + generated.P() generated.P("private fun loadSchema(rawSchemaJson: String): JsonObject = Json.parseToJsonElement(rawSchemaJson).jsonObject") generated.P() generated.P("private fun projectToolSchema(rawSchemaJson: String): ToolSchema {") diff --git a/testdata/golden/example_mcp.kt.golden b/testdata/golden/example_mcp.kt.golden new file mode 100644 index 0000000..08f97f2 --- /dev/null +++ b/testdata/golden/example_mcp.kt.golden @@ -0,0 +1,312 @@ +// Code generated by protoc-gen-mcp. DO NOT EDIT. +// source: internal/testproto/example/v1/example.proto + +package internal.testproto.example.v1 + +import com.google.protobuf.Message +import com.google.protobuf.util.JsonFormat +import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.Icon +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.TaskSupport +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.Tool +import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations +import io.modelcontextprotocol.kotlin.sdk.types.ToolExecution +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import java.util.WeakHashMap +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +private data class RegisteredTool( + val name: String, + val title: String?, + val description: String?, + val inputSchemaJson: String, + val outputSchemaJson: String, + val requestBuilder: () -> Message.Builder, + val handler: suspend (ClientConnection, Message) -> Message, + val annotations: ToolAnnotations?, + val icons: List, + val execution: ToolExecution?, +) + +private class ServerToolRegistry { + private val toolsByName = linkedMapOf() + private val sessionsWithHandlers = mutableSetOf() + var handlersInstalled: Boolean = false + + fun addTool(tool: RegisteredTool) { + require(!toolsByName.containsKey(tool.name)) { "duplicate tool registration: ${tool.name}" } + toolsByName[tool.name] = tool + } + + fun allTools(): List = toolsByName.values.toList() + + fun tool(name: String): RegisteredTool? = toolsByName[name] + + fun markSessionInstalled(sessionId: String): Boolean = sessionsWithHandlers.add(sessionId) +} + +private val serverRegistries = WeakHashMap() + +private fun installMcpHandlers(server: Server): ServerToolRegistry { + val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() } + if (registry.handlersInstalled) { + return registry + } + require(server.tools.isEmpty()) { "protoc-gen-mcp generated handlers cannot compose with pre-existing SDK tool registrations" } + installSessionHandlers(server, registry) + server.onConnect { installSessionHandlers(server, registry) } + registry.handlersInstalled = true + return registry +} + +private fun installSessionHandlers(server: Server, registry: ServerToolRegistry) { + for (session in server.sessions.values) { + if (!registry.markSessionInstalled(session.sessionId)) { + continue + } + session.setRequestHandler(Method.Defined.ToolsList) { request: ListToolsRequest, _ -> + listRegisteredTools(registry, request) + } + session.setRequestHandler(Method.Defined.ToolsCall) { request: CallToolRequest, _ -> + dispatchToolCall(registry, session.clientConnection, request) + } + } +} + +private suspend fun listRegisteredTools(registry: ServerToolRegistry, request: ListToolsRequest): ListToolsResult { + return ListToolsResult(tools = registry.allTools().map { buildListTool(it) }, nextCursor = null) +} + +private suspend fun dispatchToolCall(registry: ServerToolRegistry, ctx: ClientConnection, request: CallToolRequest): CallToolResult { + val tool = registry.tool(request.params.name) ?: invalidParams(request.params.name, "unknown tool") + val arguments = request.params.arguments ?: buildJsonObject {} + validateJson(tool.inputSchemaJson, arguments) + val requestMessage = parseProtoJson(arguments.toString(), tool.requestBuilder()) + val responseMessage = try { + tool.handler(ctx, requestMessage) + } catch (error: Exception) { + return CallToolResult(content = listOf(TextContent(text = error.message ?: error.toString())), isError = true) + } + val payload = marshalProtoJson(responseMessage) + validateJson(tool.outputSchemaJson, payload) + val textPayload = payload.toString() + return CallToolResult(content = listOf(TextContent(text = textPayload)), structuredContent = payload) +} + +private fun buildListTool(tool: RegisteredTool): Tool { + return Tool( + name = tool.name, + title = tool.title, + description = tool.description, + inputSchema = projectToolSchema(tool.inputSchemaJson), + outputSchema = projectToolSchema(tool.outputSchemaJson), + annotations = copyToolAnnotations(tool.annotations), + icons = tool.icons.ifEmpty { null }, + execution = tool.execution, + ) +} + +private fun copyToolAnnotations(annotations: ToolAnnotations?): ToolAnnotations? { + if (annotations == null) { + return null + } + return ToolAnnotations( + title = annotations.title, + readOnlyHint = annotations.readOnlyHint, + destructiveHint = annotations.destructiveHint, + idempotentHint = annotations.idempotentHint, + openWorldHint = annotations.openWorldHint, + ) +} + +private fun loadSchema(rawSchemaJson: String): JsonObject = Json.parseToJsonElement(rawSchemaJson).jsonObject + +private fun projectToolSchema(rawSchemaJson: String): ToolSchema { + val schema = loadSchema(rawSchemaJson) + val properties = schema["properties"]?.jsonObject ?: buildJsonObject {} + val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList() + return ToolSchema(properties = properties, required = required) +} + +private fun validateJson(rawSchemaJson: String, payload: JsonElement) { + val schema = loadSchema(rawSchemaJson) + val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty() + if (payload !is JsonObject) { + invalidParams("", "payload must be a JSON object") + } + val missing = required.filterNot { payload.containsKey(it) } + if (missing.isNotEmpty()) { + invalidParams("", "missing required fields: ${missing.joinToString(", ")}") + } +} + +private fun parseProtoJson(payload: String, builder: Message.Builder): Message { + try { + JsonFormat.parser().merge(payload, builder) + return builder.build() + } catch (error: Exception) { + invalidParams("", error.message ?: error.toString()) + } +} + +private fun marshalProtoJson(message: Message): JsonObject { + val jsonPayload = JsonFormat.printer().includingDefaultValueFields().print(message) + return Json.parseToJsonElement(jsonPayload).jsonObject +} + +private fun invalidParams(toolName: String, reason: String): Nothing { + throw McpException(code = RPCError.ErrorCode.INVALID_PARAMS, message = "invalid arguments for tool '$toolName': $reason") +} + +private fun toolExecutionOrNull(taskSupport: String): ToolExecution? = when (taskSupport) { + "optional" -> ToolExecution(taskSupport = TaskSupport.Optional) + "required" -> ToolExecution(taskSupport = TaskSupport.Required) + else -> null +} + +private fun iconThemeOrError(rawTheme: String?): Icon.Theme? = when (rawTheme) { + null, "" -> null + "light" -> Icon.Theme.Light + "dark" -> Icon.Theme.Dark + else -> throw IllegalArgumentException("unsupported icon theme: $rawTheme") +} + +private fun normalizeToolSegment(segment: String?): String { + return segment + ?.trim() + ?.replace('.', '_') + ?.split('_') + ?.filter { it.isNotBlank() } + ?.joinToString("_") + ?: "" +} + +private fun normalizeNamespace(namespace: String?, defaultNamespace: String): String = normalizeToolSegment(namespace ?: defaultNamespace) + +private fun toolName(namespace: String, methodName: String): String { + val normalizedNamespace = normalizeToolSegment(namespace) + val normalizedMethodName = normalizeToolSegment(methodName) + return when { + normalizedNamespace.isEmpty() -> normalizedMethodName + normalizedMethodName.isEmpty() -> normalizedNamespace + else -> "${normalizedNamespace}_${normalizedMethodName}" + } +} + +interface ExampleAPIToolHandler { + suspend fun createReport(ctx: ClientConnection, request: ExampleOuterClass.CreateReportRequest): ExampleOuterClass.CreateReportResponse + suspend fun ping(ctx: ClientConnection, request: ExampleOuterClass.PingRequest): ExampleOuterClass.PingResponse + suspend fun describeAdvancedShapes(ctx: ClientConnection, request: ExampleOuterClass.DescribeAdvancedShapesRequest): ExampleOuterClass.DescribeAdvancedShapesResponse + suspend fun describeScalarShapes(ctx: ClientConnection, request: ExampleOuterClass.DescribeScalarShapesRequest): ExampleOuterClass.DescribeScalarShapesResponse + suspend fun hiddenThing(ctx: ClientConnection, request: ExampleOuterClass.HiddenThingRequest): ExampleOuterClass.HiddenThingResponse +} + +fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespace: String? = null) { + val registry = installMcpHandlers(server) + val resolvedNamespace = normalizeNamespace(namespace, "example") + registry.addTool( + RegisteredTool( + name = toolName(resolvedNamespace, "CreateReport"), + title = "Create report", + description = "Create a report for a city.", + inputSchemaJson = EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + outputSchemaJson = EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + requestBuilder = { ExampleOuterClass.CreateReportRequest.newBuilder() }, + handler = { ctx, request -> impl.createReport(ctx, request as ExampleOuterClass.CreateReportRequest) as Message }, + annotations = null, + icons = emptyList(), + execution = toolExecutionOrNull("none"), + ), + ) + registry.addTool( + RegisteredTool( + name = toolName(resolvedNamespace, "Health"), + title = "Health check", + description = "Ping returns an empty response.", + inputSchemaJson = EXAMPLE_API_PING_INPUT_SCHEMA_JSON, + outputSchemaJson = EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON, + requestBuilder = { ExampleOuterClass.PingRequest.newBuilder() }, + handler = { ctx, request -> impl.ping(ctx, request as ExampleOuterClass.PingRequest) as Message }, + annotations = null, + icons = emptyList(), + execution = toolExecutionOrNull("none"), + ), + ) + registry.addTool( + RegisteredTool( + name = toolName(resolvedNamespace, "DescribeAdvancedShapes"), + title = "Describe advanced shapes", + description = "Exercise maps and well-known protobuf types.", + inputSchemaJson = EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON, + outputSchemaJson = EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON, + requestBuilder = { ExampleOuterClass.DescribeAdvancedShapesRequest.newBuilder() }, + handler = { ctx, request -> impl.describeAdvancedShapes(ctx, request as ExampleOuterClass.DescribeAdvancedShapesRequest) as Message }, + annotations = null, + icons = emptyList(), + execution = toolExecutionOrNull("none"), + ), + ) + registry.addTool( + RegisteredTool( + name = toolName(resolvedNamespace, "DescribeScalarShapes"), + title = "Describe scalar shapes", + description = "Exercise plain protobuf scalar kinds.", + inputSchemaJson = EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON, + outputSchemaJson = EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON, + requestBuilder = { ExampleOuterClass.DescribeScalarShapesRequest.newBuilder() }, + handler = { ctx, request -> impl.describeScalarShapes(ctx, request as ExampleOuterClass.DescribeScalarShapesRequest) as Message }, + annotations = null, + icons = emptyList(), + execution = toolExecutionOrNull("none"), + ), + ) + registry.addTool( + RegisteredTool( + name = toolName(resolvedNamespace, "HiddenThing"), + title = null, + description = "HiddenThing is intentionally hidden from generated tools.\nNote: the `hidden` method option was removed in this iteration.", + inputSchemaJson = EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON, + outputSchemaJson = EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON, + requestBuilder = { ExampleOuterClass.HiddenThingRequest.newBuilder() }, + handler = { ctx, request -> impl.hiddenThing(ctx, request as ExampleOuterClass.HiddenThingRequest) as Message }, + annotations = null, + icons = emptyList(), + execution = toolExecutionOrNull("none"), + ), + ) +} + +private const val EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"description\":\"City name.\",\"examples\":[\"Paris\",\"London\"],\"minLength\":1,\"maxLength\":100,\"pattern\":\"^[A-Z]\"},\"count\":{\"type\":\"integer\",\"description\":\"count is the number of requested items.\",\"default\":10,\"examples\":[-1],\"minimum\":1,\"maximum\":1000},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details contains nested report options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"labels\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"labels adds additional labels.\",\"examples\":[\"example\"]},\"description\":\"labels adds additional labels.\",\"examples\":[[\"example\"]],\"minItems\":1,\"maxItems\":50,\"uniqueItems\":true},\"units\":{\"type\":[\"string\",\"null\"],\"description\":\"units overrides the default units.\",\"examples\":[\"example\"]}},\"description\":\"CreateReportRequest describes a report generation request.\",\"examples\":[\"{\\\"city\\\":\\\"Paris\\\",\\\"count\\\":2,\\\"details\\\":{\\\"label\\\":\\\"today\\\"}}\"],\"required\":[\"city\",\"count\",\"details\"],\"additionalProperties\":false}" + +private const val EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details echoes the nested options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"reportId\":{\"type\":\"string\",\"description\":\"report_id is the generated report identifier.\",\"examples\":[\"example\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status is the final report status.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"totalCount\":{\"type\":\"string\",\"description\":\"total_count is returned as a ProtoJSON string.\",\"examples\":[\"-1\"]},\"warnings\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"warnings contains optional warning messages.\",\"examples\":[\"example\"]},\"description\":\"warnings contains optional warning messages.\",\"examples\":[[\"example\"]]}},\"description\":\"CreateReportResponse describes a report generation result.\",\"examples\":[{\"details\":{\"label\":\"example\"},\"reportId\":\"example\",\"status\":\"REPORT_STATUS_OK\",\"totalCount\":\"-1\",\"warnings\":[\"example\"]}],\"required\":[\"reportId\",\"totalCount\",\"status\",\"details\"],\"additionalProperties\":false}" + +private const val EXAMPLE_API_PING_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"description\":\"PingRequest is used by the health-check RPC.\",\"additionalProperties\":false}" + +private const val EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"ack\":{\"type\":\"object\",\"description\":\"ack confirms the health-check call.\",\"examples\":[{}],\"additionalProperties\":false}},\"description\":\"PingResponse is used by the health-check RPC.\",\"examples\":[{\"ack\":{}}],\"required\":[\"ack\"],\"additionalProperties\":false}" + +private const val EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesRequest exercises supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}" + +private const val EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesResponse echoes supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}" + +private const val EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"boolFlag\":{\"type\":\"boolean\",\"description\":\"bool_flag exercises the plain bool kind.\",\"examples\":[true]},\"bytesValue\":{\"type\":\"string\",\"description\":\"bytes_value exercises the plain bytes kind.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details exercises plain nested message handling.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"doubleValue\":{\"description\":\"double_value exercises the plain double kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"fixed32Value\":{\"type\":\"integer\",\"description\":\"fixed32_value exercises the plain fixed32 kind.\",\"examples\":[1]},\"fixed64Value\":{\"type\":\"string\",\"description\":\"fixed64_value exercises the plain fixed64 kind.\",\"examples\":[\"1\"]},\"floatValue\":{\"description\":\"float_value exercises the plain float kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"int32Value\":{\"type\":\"integer\",\"description\":\"int32_value exercises the plain int32 kind.\",\"examples\":[-1]},\"int64Value\":{\"type\":\"string\",\"description\":\"int64_value exercises the plain int64 kind.\",\"examples\":[\"-1\"]},\"optionalBoolFlag\":{\"type\":[\"boolean\",\"null\"],\"description\":\"optional_bool_flag exercises proto3 optional bool.\",\"examples\":[true]},\"optionalBytesValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_bytes_value exercises proto3 optional bytes.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"optionalDoubleValue\":{\"description\":\"optional_double_value exercises proto3 optional double.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalFixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_fixed32_value exercises proto3 optional fixed32.\",\"examples\":[1]},\"optionalFixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_fixed64_value exercises proto3 optional fixed64.\",\"examples\":[\"1\"]},\"optionalFloatValue\":{\"description\":\"optional_float_value exercises proto3 optional float.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalInt32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_int32_value exercises proto3 optional int32.\",\"examples\":[-1]},\"optionalInt64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_int64_value exercises proto3 optional int64.\",\"examples\":[\"-1\"]},\"optionalSfixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sfixed32_value exercises proto3 optional sfixed32.\",\"examples\":[-1]},\"optionalSfixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sfixed64_value exercises proto3 optional sfixed64.\",\"examples\":[\"-1\"]},\"optionalSint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sint32_value exercises proto3 optional sint32.\",\"examples\":[-1]},\"optionalSint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sint64_value exercises proto3 optional sint64.\",\"examples\":[\"-1\"]},\"optionalStatus\":{\"description\":\"optional_status exercises proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"anyOf\":[{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"optional_status exercises proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},{\"type\":\"null\"}]},\"optionalTextValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_text_value exercises proto3 optional string.\",\"examples\":[\"example\"]},\"optionalUint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_uint32_value exercises proto3 optional uint32.\",\"examples\":[1]},\"optionalUint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_uint64_value exercises proto3 optional uint64.\",\"examples\":[\"1\"]},\"samples\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"integer\",\"description\":\"samples exercises repeated plain scalar handling.\",\"examples\":[-1]},\"description\":\"samples exercises repeated plain scalar handling.\",\"examples\":[[-1]]},\"sfixed32Value\":{\"type\":\"integer\",\"description\":\"sfixed32_value exercises the plain sfixed32 kind.\",\"examples\":[-1]},\"sfixed64Value\":{\"type\":\"string\",\"description\":\"sfixed64_value exercises the plain sfixed64 kind.\",\"examples\":[\"-1\"]},\"sint32Value\":{\"type\":\"integer\",\"description\":\"sint32_value exercises the plain sint32 kind.\",\"examples\":[-1]},\"sint64Value\":{\"type\":\"string\",\"description\":\"sint64_value exercises the plain sint64 kind.\",\"examples\":[\"-1\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status exercises plain enum handling.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"textValue\":{\"type\":\"string\",\"description\":\"text_value exercises the plain string kind.\",\"examples\":[\"example\"]},\"uint32Value\":{\"type\":\"integer\",\"description\":\"uint32_value exercises the plain uint32 kind.\",\"examples\":[1]},\"uint64Value\":{\"type\":\"string\",\"description\":\"uint64_value exercises the plain uint64 kind.\",\"examples\":[\"1\"]}},\"description\":\"DescribeScalarShapesRequest exercises plain protobuf scalar kinds.\",\"examples\":[{\"boolFlag\":true,\"bytesValue\":\"aGVsbG8=\",\"details\":{\"label\":\"example\"},\"doubleValue\":1.25,\"fixed32Value\":1,\"fixed64Value\":\"1\",\"floatValue\":1.25,\"int32Value\":-1,\"int64Value\":\"-1\",\"optionalBoolFlag\":true,\"optionalBytesValue\":\"aGVsbG8=\",\"optionalDoubleValue\":1.25,\"optionalFixed32Value\":1,\"optionalFixed64Value\":\"1\",\"optionalFloatValue\":1.25,\"optionalInt32Value\":-1,\"optionalInt64Value\":\"-1\",\"optionalSfixed32Value\":-1,\"optionalSfixed64Value\":\"-1\",\"optionalSint32Value\":-1,\"optionalSint64Value\":\"-1\",\"optionalStatus\":\"REPORT_STATUS_OK\",\"optionalTextValue\":\"example\",\"optionalUint32Value\":1,\"optionalUint64Value\":\"1\",\"samples\":[-1],\"sfixed32Value\":-1,\"sfixed64Value\":\"-1\",\"sint32Value\":-1,\"sint64Value\":\"-1\",\"status\":\"REPORT_STATUS_OK\",\"textValue\":\"example\",\"uint32Value\":1,\"uint64Value\":\"1\"}],\"required\":[\"boolFlag\",\"textValue\",\"bytesValue\",\"int32Value\",\"sint32Value\",\"sfixed32Value\",\"uint32Value\",\"fixed32Value\",\"int64Value\",\"sint64Value\",\"sfixed64Value\",\"uint64Value\",\"fixed64Value\",\"floatValue\",\"doubleValue\",\"status\",\"details\"],\"additionalProperties\":false}" + +private const val EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"boolFlag\":{\"type\":\"boolean\",\"description\":\"bool_flag echoes the plain bool kind.\",\"examples\":[true]},\"bytesValue\":{\"type\":\"string\",\"description\":\"bytes_value echoes the plain bytes kind.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details echoes plain nested message handling.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"doubleValue\":{\"description\":\"double_value echoes the plain double kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"fixed32Value\":{\"type\":\"integer\",\"description\":\"fixed32_value echoes the plain fixed32 kind.\",\"examples\":[1]},\"fixed64Value\":{\"type\":\"string\",\"description\":\"fixed64_value echoes the plain fixed64 kind.\",\"examples\":[\"1\"]},\"floatValue\":{\"description\":\"float_value echoes the plain float kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"int32Value\":{\"type\":\"integer\",\"description\":\"int32_value echoes the plain int32 kind.\",\"examples\":[-1]},\"int64Value\":{\"type\":\"string\",\"description\":\"int64_value echoes the plain int64 kind.\",\"examples\":[\"-1\"]},\"optionalBoolFlag\":{\"type\":[\"boolean\",\"null\"],\"description\":\"optional_bool_flag echoes proto3 optional bool.\",\"examples\":[true]},\"optionalBytesValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_bytes_value echoes proto3 optional bytes.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"optionalDoubleValue\":{\"description\":\"optional_double_value echoes proto3 optional double.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalFixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_fixed32_value echoes proto3 optional fixed32.\",\"examples\":[1]},\"optionalFixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_fixed64_value echoes proto3 optional fixed64.\",\"examples\":[\"1\"]},\"optionalFloatValue\":{\"description\":\"optional_float_value echoes proto3 optional float.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalInt32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_int32_value echoes proto3 optional int32.\",\"examples\":[-1]},\"optionalInt64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_int64_value echoes proto3 optional int64.\",\"examples\":[\"-1\"]},\"optionalSfixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sfixed32_value echoes proto3 optional sfixed32.\",\"examples\":[-1]},\"optionalSfixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sfixed64_value echoes proto3 optional sfixed64.\",\"examples\":[\"-1\"]},\"optionalSint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sint32_value echoes proto3 optional sint32.\",\"examples\":[-1]},\"optionalSint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sint64_value echoes proto3 optional sint64.\",\"examples\":[\"-1\"]},\"optionalStatus\":{\"description\":\"optional_status echoes proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"anyOf\":[{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"optional_status echoes proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},{\"type\":\"null\"}]},\"optionalTextValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_text_value echoes proto3 optional string.\",\"examples\":[\"example\"]},\"optionalUint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_uint32_value echoes proto3 optional uint32.\",\"examples\":[1]},\"optionalUint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_uint64_value echoes proto3 optional uint64.\",\"examples\":[\"1\"]},\"samples\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"integer\",\"description\":\"samples echoes repeated plain scalar handling.\",\"examples\":[-1]},\"description\":\"samples echoes repeated plain scalar handling.\",\"examples\":[[-1]]},\"sfixed32Value\":{\"type\":\"integer\",\"description\":\"sfixed32_value echoes the plain sfixed32 kind.\",\"examples\":[-1]},\"sfixed64Value\":{\"type\":\"string\",\"description\":\"sfixed64_value echoes the plain sfixed64 kind.\",\"examples\":[\"-1\"]},\"sint32Value\":{\"type\":\"integer\",\"description\":\"sint32_value echoes the plain sint32 kind.\",\"examples\":[-1]},\"sint64Value\":{\"type\":\"string\",\"description\":\"sint64_value echoes the plain sint64 kind.\",\"examples\":[\"-1\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status echoes plain enum handling.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"textValue\":{\"type\":\"string\",\"description\":\"text_value echoes the plain string kind.\",\"examples\":[\"example\"]},\"uint32Value\":{\"type\":\"integer\",\"description\":\"uint32_value echoes the plain uint32 kind.\",\"examples\":[1]},\"uint64Value\":{\"type\":\"string\",\"description\":\"uint64_value echoes the plain uint64 kind.\",\"examples\":[\"1\"]}},\"description\":\"DescribeScalarShapesResponse echoes plain protobuf scalar kinds.\",\"examples\":[{\"boolFlag\":true,\"bytesValue\":\"aGVsbG8=\",\"details\":{\"label\":\"example\"},\"doubleValue\":1.25,\"fixed32Value\":1,\"fixed64Value\":\"1\",\"floatValue\":1.25,\"int32Value\":-1,\"int64Value\":\"-1\",\"optionalBoolFlag\":true,\"optionalBytesValue\":\"aGVsbG8=\",\"optionalDoubleValue\":1.25,\"optionalFixed32Value\":1,\"optionalFixed64Value\":\"1\",\"optionalFloatValue\":1.25,\"optionalInt32Value\":-1,\"optionalInt64Value\":\"-1\",\"optionalSfixed32Value\":-1,\"optionalSfixed64Value\":\"-1\",\"optionalSint32Value\":-1,\"optionalSint64Value\":\"-1\",\"optionalStatus\":\"REPORT_STATUS_OK\",\"optionalTextValue\":\"example\",\"optionalUint32Value\":1,\"optionalUint64Value\":\"1\",\"samples\":[-1],\"sfixed32Value\":-1,\"sfixed64Value\":\"-1\",\"sint32Value\":-1,\"sint64Value\":\"-1\",\"status\":\"REPORT_STATUS_OK\",\"textValue\":\"example\",\"uint32Value\":1,\"uint64Value\":\"1\"}],\"required\":[\"boolFlag\",\"textValue\",\"bytesValue\",\"int32Value\",\"sint32Value\",\"sfixed32Value\",\"uint32Value\",\"fixed32Value\",\"int64Value\",\"sint64Value\",\"sfixed64Value\",\"uint64Value\",\"fixed64Value\",\"floatValue\",\"doubleValue\",\"status\",\"details\"],\"additionalProperties\":false}" + +private const val EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"description\":\"name is the hidden request payload.\",\"examples\":[\"example\"]}},\"description\":\"HiddenThingRequest is used by the hidden RPC.\",\"deprecated\":true,\"examples\":[{\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}" + +private const val EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"description\":\"HiddenThingResponse is used by the hidden RPC.\",\"additionalProperties\":false}" From 5b7d42fa3f8173488bbb0717c964f68919860032 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 22:38:13 +0300 Subject: [PATCH 15/74] test(04-01): add failing JVM outer class resolver coverage - cover protoc-compatible default outer class naming - assert default wrapper refs stop appending OuterClass unnecessarily --- internal/codegen/jvm_package_resolver_test.go | 82 ++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/internal/codegen/jvm_package_resolver_test.go b/internal/codegen/jvm_package_resolver_test.go index 508af6f..e025b67 100644 --- a/internal/codegen/jvm_package_resolver_test.go +++ b/internal/codegen/jvm_package_resolver_test.go @@ -45,11 +45,87 @@ func TestResolveJVMFilePackage_UsesJavaPackageAndProtoFallback(t *testing.T) { if got, want := fallback.Package, "fallback.v1"; got != want { t.Fatalf("fallback Package = %q, want %q", got, want) } - if got, want := fallback.OuterClassName, "ProtoFallbackOuterClass"; got != want { + if got, want := fallback.OuterClassName, "ProtoFallback"; got != want { t.Fatalf("fallback OuterClassName = %q, want %q", got, want) } } +func TestResolveJVMFilePackage_DefaultOuterClassnameMatchesProtoc(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/example.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/example;examplev1";`, + `message PingRequest {}`, + "", + }, "\n"), + "test/v1/default_outer.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/defaultouter;defaultouterv1";`, + `message DefaultRequest {}`, + "", + }, "\n"), + "test/v1/message_conflict/example.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.messageconflict.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/messageconflict;messageconflictv1";`, + `message Example {}`, + "", + }, "\n"), + "test/v1/enum_conflict/example.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.enumconflict.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/enumconflict;enumconflictv1";`, + `enum Example { EXAMPLE_UNSPECIFIED = 0; }`, + "", + }, "\n"), + "test/v1/service_conflict/example.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.serviceconflict.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/serviceconflict;serviceconflictv1";`, + `message PingRequest {}`, + `message PingResponse {}`, + `service Example {`, + ` rpc Ping(PingRequest) returns (PingResponse);`, + `}`, + "", + }, "\n"), + "test/v1/wrapped.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/wrapped;wrappedv1";`, + `option java_outer_classname = "WrappedTypes";`, + `message WrappedRequest {}`, + "", + }, "\n"), + }, "test/v1/example.proto", "test/v1/default_outer.proto", "test/v1/message_conflict/example.proto", "test/v1/enum_conflict/example.proto", "test/v1/service_conflict/example.proto", "test/v1/wrapped.proto") + + testCases := []struct { + path string + want string + }{ + {path: "test/v1/example.proto", want: "Example"}, + {path: "test/v1/default_outer.proto", want: "DefaultOuter"}, + {path: "test/v1/message_conflict/example.proto", want: "ExampleOuterClass"}, + {path: "test/v1/enum_conflict/example.proto", want: "ExampleOuterClass"}, + {path: "test/v1/service_conflict/example.proto", want: "ExampleOuterClass"}, + {path: "test/v1/wrapped.proto", want: "WrappedTypes"}, + } + + for _, tc := range testCases { + t.Run(tc.path, func(t *testing.T) { + got, err := resolveJVMFilePackage(plugin.FilesByPath[tc.path]) + if err != nil { + t.Fatalf("resolveJVMFilePackage(%q): %v", tc.path, err) + } + if got.OuterClassName != tc.want { + t.Fatalf("OuterClassName = %q, want %q", got.OuterClassName, tc.want) + } + }) + } +} + func TestResolveJVMMessageTypeRef_RespectsJavaMultipleFilesAndOuterClassname(t *testing.T) { plugin := newTempProtogenPlugin(t, map[string]string{ "test/v1/shared.proto": strings.Join([]string{ @@ -142,10 +218,10 @@ func TestResolveJVMMessageTypeRef_UsesDefaultOuterClassname(t *testing.T) { if err != nil { t.Fatalf("resolve default outer message: %v", err) } - if got, want := ref.ImportPath, "com.example.defaultouter.DefaultOuterOuterClass"; got != want { + if got, want := ref.ImportPath, "com.example.defaultouter.DefaultOuter"; got != want { t.Fatalf("ImportPath = %q, want %q", got, want) } - if got, want := ref.Expr, "DefaultOuterOuterClass.DefaultRequest"; got != want { + if got, want := ref.Expr, "DefaultOuter.DefaultRequest"; got != want { t.Fatalf("Expr = %q, want %q", got, want) } } From f71ea5d250a75b6d7ffb1d2927bd69e5a61c0d35 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 22:41:05 +0300 Subject: [PATCH 16/74] fix(04-01): align JVM outer class resolution with protoc - resolve default JVM outer classes from descriptor-aware proto file names - refresh Kotlin and Java example goldens to use protoc-compatible wrappers --- internal/codegen/jvm_package_resolver.go | 33 +- testdata/golden/example_mcp.java.golden | 530 +++++++++++++++++++++++ testdata/golden/example_mcp.kt.golden | 30 +- 3 files changed, 573 insertions(+), 20 deletions(-) create mode 100644 testdata/golden/example_mcp.java.golden diff --git a/internal/codegen/jvm_package_resolver.go b/internal/codegen/jvm_package_resolver.go index b598015..81a5959 100644 --- a/internal/codegen/jvm_package_resolver.go +++ b/internal/codegen/jvm_package_resolver.go @@ -36,7 +36,7 @@ func resolveJVMDescriptorFilePackage(file protoreflect.FileDescriptor) (jvmResol outerClassName := strings.TrimSpace(options.GetJavaOuterClassname()) if outerClassName == "" { - outerClassName = defaultJVMOuterClassName(file.Path()) + outerClassName = defaultJVMOuterClassName(file) } return jvmResolvedFilePackage{ @@ -88,7 +88,8 @@ func resolveJVMTypeRef(file protoreflect.FileDescriptor, publicName, currentProt }, nil } -func defaultJVMOuterClassName(protoPath string) string { +func defaultJVMOuterClassName(file protoreflect.FileDescriptor) string { + protoPath := file.Path() base := strings.TrimSuffix(path.Base(protoPath), path.Ext(protoPath)) var b strings.Builder nextUpper := true @@ -105,8 +106,30 @@ func defaultJVMOuterClassName(protoPath string) string { b.WriteRune(r) } if b.Len() == 0 { - return "ProtoOuterClass" + b.WriteString("Proto") } - b.WriteString("OuterClass") - return b.String() + candidate := b.String() + if jvmOuterClassNameConflicts(file, candidate) { + return candidate + "OuterClass" + } + return candidate +} + +func jvmOuterClassNameConflicts(file protoreflect.FileDescriptor, candidate string) bool { + for i := 0; i < file.Messages().Len(); i++ { + if string(file.Messages().Get(i).Name()) == candidate { + return true + } + } + for i := 0; i < file.Enums().Len(); i++ { + if string(file.Enums().Get(i).Name()) == candidate { + return true + } + } + for i := 0; i < file.Services().Len(); i++ { + if string(file.Services().Get(i).Name()) == candidate { + return true + } + } + return false } diff --git a/testdata/golden/example_mcp.java.golden b/testdata/golden/example_mcp.java.golden new file mode 100644 index 0000000..4479aa2 --- /dev/null +++ b/testdata/golden/example_mcp.java.golden @@ -0,0 +1,530 @@ +// Code generated by protoc-gen-mcp. DO NOT EDIT. +// source: internal/testproto/example/v1/example.proto + +package internal.testproto.example.v1; + +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpInitRequestHandler; +import io.modelcontextprotocol.server.McpNotificationHandler; +import io.modelcontextprotocol.server.McpRequestHandler; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; +import java.util.function.Supplier; +import reactor.core.publisher.Mono; + +public final class ExampleMcp { + private ExampleMcp() { + } + + public interface ExampleAPIToolHandler { + Example.CreateReportResponse createReport(McpAsyncServerExchange ctx, Example.CreateReportRequest request) throws Exception; + Example.PingResponse ping(McpAsyncServerExchange ctx, Example.PingRequest request) throws Exception; + Example.DescribeAdvancedShapesResponse describeAdvancedShapes(McpAsyncServerExchange ctx, Example.DescribeAdvancedShapesRequest request) throws Exception; + Example.DescribeScalarShapesResponse describeScalarShapes(McpAsyncServerExchange ctx, Example.DescribeScalarShapesRequest request) throws Exception; + Example.HiddenThingResponse hiddenThing(McpAsyncServerExchange ctx, Example.HiddenThingRequest request) throws Exception; + } + + public static void registerExampleAPITools( + McpServerTransportProvider transportProvider, + ExampleAPIToolHandler impl, + String namespace) { + if (transportProvider == null) { + throw new IllegalArgumentException("transportProvider must not be null"); + } + if (impl == null) { + throw new IllegalArgumentException("impl must not be null"); + } + ServerToolRegistry registry = registryFor(transportProvider); + String resolvedNamespace = normalizeNamespace(namespace, "example"); + registry.registerTool( + new RegisteredTool( + toolName(resolvedNamespace, "CreateReport"), + "Create report", + "Create a report for a city.", + EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + () -> Example.CreateReportRequest.newBuilder(), + () -> Example.CreateReportResponse.newBuilder(), + (ctx, request) -> impl.createReport(ctx, (Example.CreateReportRequest) request), + null, + List.of(), + toolExecutionMapOrNull(null)) + ); + registry.registerTool( + new RegisteredTool( + toolName(resolvedNamespace, "Health"), + "Health check", + "Ping returns an empty response.", + EXAMPLE_API_PING_INPUT_SCHEMA_JSON, + EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON, + () -> Example.PingRequest.newBuilder(), + () -> Example.PingResponse.newBuilder(), + (ctx, request) -> impl.ping(ctx, (Example.PingRequest) request), + null, + List.of(), + toolExecutionMapOrNull(null)) + ); + registry.registerTool( + new RegisteredTool( + toolName(resolvedNamespace, "DescribeAdvancedShapes"), + "Describe advanced shapes", + "Exercise maps and well-known protobuf types.", + EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON, + EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON, + () -> Example.DescribeAdvancedShapesRequest.newBuilder(), + () -> Example.DescribeAdvancedShapesResponse.newBuilder(), + (ctx, request) -> impl.describeAdvancedShapes(ctx, (Example.DescribeAdvancedShapesRequest) request), + null, + List.of(), + toolExecutionMapOrNull(null)) + ); + registry.registerTool( + new RegisteredTool( + toolName(resolvedNamespace, "DescribeScalarShapes"), + "Describe scalar shapes", + "Exercise plain protobuf scalar kinds.", + EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON, + EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON, + () -> Example.DescribeScalarShapesRequest.newBuilder(), + () -> Example.DescribeScalarShapesResponse.newBuilder(), + (ctx, request) -> impl.describeScalarShapes(ctx, (Example.DescribeScalarShapesRequest) request), + null, + List.of(), + toolExecutionMapOrNull(null)) + ); + registry.registerTool( + new RegisteredTool( + toolName(resolvedNamespace, "HiddenThing"), + null, + "HiddenThing is intentionally hidden from generated tools.\nNote: the `hidden` method option was removed in this iteration.", + EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON, + EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON, + () -> Example.HiddenThingRequest.newBuilder(), + () -> Example.HiddenThingResponse.newBuilder(), + (ctx, request) -> impl.hiddenThing(ctx, (Example.HiddenThingRequest) request), + null, + List.of(), + toolExecutionMapOrNull(null)) + ); + installSessionFactory(transportProvider, registry); + } + + private static final String EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"description\":\"City name.\",\"examples\":[\"Paris\",\"London\"],\"minLength\":1,\"maxLength\":100,\"pattern\":\"^[A-Z]\"},\"count\":{\"type\":\"integer\",\"description\":\"count is the number of requested items.\",\"default\":10,\"examples\":[-1],\"minimum\":1,\"maximum\":1000},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details contains nested report options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"labels\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"labels adds additional labels.\",\"examples\":[\"example\"]},\"description\":\"labels adds additional labels.\",\"examples\":[[\"example\"]],\"minItems\":1,\"maxItems\":50,\"uniqueItems\":true},\"units\":{\"type\":[\"string\",\"null\"],\"description\":\"units overrides the default units.\",\"examples\":[\"example\"]}},\"description\":\"CreateReportRequest describes a report generation request.\",\"examples\":[\"{\\\"city\\\":\\\"Paris\\\",\\\"count\\\":2,\\\"details\\\":{\\\"label\\\":\\\"today\\\"}}\"],\"required\":[\"city\",\"count\",\"details\"],\"additionalProperties\":false}"; + + private static final String EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details echoes the nested options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"reportId\":{\"type\":\"string\",\"description\":\"report_id is the generated report identifier.\",\"examples\":[\"example\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status is the final report status.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"totalCount\":{\"type\":\"string\",\"description\":\"total_count is returned as a ProtoJSON string.\",\"examples\":[\"-1\"]},\"warnings\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"warnings contains optional warning messages.\",\"examples\":[\"example\"]},\"description\":\"warnings contains optional warning messages.\",\"examples\":[[\"example\"]]}},\"description\":\"CreateReportResponse describes a report generation result.\",\"examples\":[{\"details\":{\"label\":\"example\"},\"reportId\":\"example\",\"status\":\"REPORT_STATUS_OK\",\"totalCount\":\"-1\",\"warnings\":[\"example\"]}],\"required\":[\"reportId\",\"totalCount\",\"status\",\"details\"],\"additionalProperties\":false}"; + + private static final String EXAMPLE_API_PING_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"description\":\"PingRequest is used by the health-check RPC.\",\"additionalProperties\":false}"; + + private static final String EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"ack\":{\"type\":\"object\",\"description\":\"ack confirms the health-check call.\",\"examples\":[{}],\"additionalProperties\":false}},\"description\":\"PingResponse is used by the health-check RPC.\",\"examples\":[{\"ack\":{}}],\"required\":[\"ack\"],\"additionalProperties\":false}"; + + private static final String EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesRequest exercises supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}"; + + private static final String EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesResponse echoes supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}"; + + private static final String EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"boolFlag\":{\"type\":\"boolean\",\"description\":\"bool_flag exercises the plain bool kind.\",\"examples\":[true]},\"bytesValue\":{\"type\":\"string\",\"description\":\"bytes_value exercises the plain bytes kind.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details exercises plain nested message handling.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"doubleValue\":{\"description\":\"double_value exercises the plain double kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"fixed32Value\":{\"type\":\"integer\",\"description\":\"fixed32_value exercises the plain fixed32 kind.\",\"examples\":[1]},\"fixed64Value\":{\"type\":\"string\",\"description\":\"fixed64_value exercises the plain fixed64 kind.\",\"examples\":[\"1\"]},\"floatValue\":{\"description\":\"float_value exercises the plain float kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"int32Value\":{\"type\":\"integer\",\"description\":\"int32_value exercises the plain int32 kind.\",\"examples\":[-1]},\"int64Value\":{\"type\":\"string\",\"description\":\"int64_value exercises the plain int64 kind.\",\"examples\":[\"-1\"]},\"optionalBoolFlag\":{\"type\":[\"boolean\",\"null\"],\"description\":\"optional_bool_flag exercises proto3 optional bool.\",\"examples\":[true]},\"optionalBytesValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_bytes_value exercises proto3 optional bytes.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"optionalDoubleValue\":{\"description\":\"optional_double_value exercises proto3 optional double.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalFixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_fixed32_value exercises proto3 optional fixed32.\",\"examples\":[1]},\"optionalFixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_fixed64_value exercises proto3 optional fixed64.\",\"examples\":[\"1\"]},\"optionalFloatValue\":{\"description\":\"optional_float_value exercises proto3 optional float.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalInt32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_int32_value exercises proto3 optional int32.\",\"examples\":[-1]},\"optionalInt64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_int64_value exercises proto3 optional int64.\",\"examples\":[\"-1\"]},\"optionalSfixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sfixed32_value exercises proto3 optional sfixed32.\",\"examples\":[-1]},\"optionalSfixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sfixed64_value exercises proto3 optional sfixed64.\",\"examples\":[\"-1\"]},\"optionalSint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sint32_value exercises proto3 optional sint32.\",\"examples\":[-1]},\"optionalSint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sint64_value exercises proto3 optional sint64.\",\"examples\":[\"-1\"]},\"optionalStatus\":{\"description\":\"optional_status exercises proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"anyOf\":[{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"optional_status exercises proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},{\"type\":\"null\"}]},\"optionalTextValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_text_value exercises proto3 optional string.\",\"examples\":[\"example\"]},\"optionalUint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_uint32_value exercises proto3 optional uint32.\",\"examples\":[1]},\"optionalUint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_uint64_value exercises proto3 optional uint64.\",\"examples\":[\"1\"]},\"samples\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"integer\",\"description\":\"samples exercises repeated plain scalar handling.\",\"examples\":[-1]},\"description\":\"samples exercises repeated plain scalar handling.\",\"examples\":[[-1]]},\"sfixed32Value\":{\"type\":\"integer\",\"description\":\"sfixed32_value exercises the plain sfixed32 kind.\",\"examples\":[-1]},\"sfixed64Value\":{\"type\":\"string\",\"description\":\"sfixed64_value exercises the plain sfixed64 kind.\",\"examples\":[\"-1\"]},\"sint32Value\":{\"type\":\"integer\",\"description\":\"sint32_value exercises the plain sint32 kind.\",\"examples\":[-1]},\"sint64Value\":{\"type\":\"string\",\"description\":\"sint64_value exercises the plain sint64 kind.\",\"examples\":[\"-1\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status exercises plain enum handling.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"textValue\":{\"type\":\"string\",\"description\":\"text_value exercises the plain string kind.\",\"examples\":[\"example\"]},\"uint32Value\":{\"type\":\"integer\",\"description\":\"uint32_value exercises the plain uint32 kind.\",\"examples\":[1]},\"uint64Value\":{\"type\":\"string\",\"description\":\"uint64_value exercises the plain uint64 kind.\",\"examples\":[\"1\"]}},\"description\":\"DescribeScalarShapesRequest exercises plain protobuf scalar kinds.\",\"examples\":[{\"boolFlag\":true,\"bytesValue\":\"aGVsbG8=\",\"details\":{\"label\":\"example\"},\"doubleValue\":1.25,\"fixed32Value\":1,\"fixed64Value\":\"1\",\"floatValue\":1.25,\"int32Value\":-1,\"int64Value\":\"-1\",\"optionalBoolFlag\":true,\"optionalBytesValue\":\"aGVsbG8=\",\"optionalDoubleValue\":1.25,\"optionalFixed32Value\":1,\"optionalFixed64Value\":\"1\",\"optionalFloatValue\":1.25,\"optionalInt32Value\":-1,\"optionalInt64Value\":\"-1\",\"optionalSfixed32Value\":-1,\"optionalSfixed64Value\":\"-1\",\"optionalSint32Value\":-1,\"optionalSint64Value\":\"-1\",\"optionalStatus\":\"REPORT_STATUS_OK\",\"optionalTextValue\":\"example\",\"optionalUint32Value\":1,\"optionalUint64Value\":\"1\",\"samples\":[-1],\"sfixed32Value\":-1,\"sfixed64Value\":\"-1\",\"sint32Value\":-1,\"sint64Value\":\"-1\",\"status\":\"REPORT_STATUS_OK\",\"textValue\":\"example\",\"uint32Value\":1,\"uint64Value\":\"1\"}],\"required\":[\"boolFlag\",\"textValue\",\"bytesValue\",\"int32Value\",\"sint32Value\",\"sfixed32Value\",\"uint32Value\",\"fixed32Value\",\"int64Value\",\"sint64Value\",\"sfixed64Value\",\"uint64Value\",\"fixed64Value\",\"floatValue\",\"doubleValue\",\"status\",\"details\"],\"additionalProperties\":false}"; + + private static final String EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"boolFlag\":{\"type\":\"boolean\",\"description\":\"bool_flag echoes the plain bool kind.\",\"examples\":[true]},\"bytesValue\":{\"type\":\"string\",\"description\":\"bytes_value echoes the plain bytes kind.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details echoes plain nested message handling.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"doubleValue\":{\"description\":\"double_value echoes the plain double kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"fixed32Value\":{\"type\":\"integer\",\"description\":\"fixed32_value echoes the plain fixed32 kind.\",\"examples\":[1]},\"fixed64Value\":{\"type\":\"string\",\"description\":\"fixed64_value echoes the plain fixed64 kind.\",\"examples\":[\"1\"]},\"floatValue\":{\"description\":\"float_value echoes the plain float kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"int32Value\":{\"type\":\"integer\",\"description\":\"int32_value echoes the plain int32 kind.\",\"examples\":[-1]},\"int64Value\":{\"type\":\"string\",\"description\":\"int64_value echoes the plain int64 kind.\",\"examples\":[\"-1\"]},\"optionalBoolFlag\":{\"type\":[\"boolean\",\"null\"],\"description\":\"optional_bool_flag echoes proto3 optional bool.\",\"examples\":[true]},\"optionalBytesValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_bytes_value echoes proto3 optional bytes.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"optionalDoubleValue\":{\"description\":\"optional_double_value echoes proto3 optional double.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalFixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_fixed32_value echoes proto3 optional fixed32.\",\"examples\":[1]},\"optionalFixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_fixed64_value echoes proto3 optional fixed64.\",\"examples\":[\"1\"]},\"optionalFloatValue\":{\"description\":\"optional_float_value echoes proto3 optional float.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalInt32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_int32_value echoes proto3 optional int32.\",\"examples\":[-1]},\"optionalInt64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_int64_value echoes proto3 optional int64.\",\"examples\":[\"-1\"]},\"optionalSfixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sfixed32_value echoes proto3 optional sfixed32.\",\"examples\":[-1]},\"optionalSfixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sfixed64_value echoes proto3 optional sfixed64.\",\"examples\":[\"-1\"]},\"optionalSint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sint32_value echoes proto3 optional sint32.\",\"examples\":[-1]},\"optionalSint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sint64_value echoes proto3 optional sint64.\",\"examples\":[\"-1\"]},\"optionalStatus\":{\"description\":\"optional_status echoes proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"anyOf\":[{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"optional_status echoes proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},{\"type\":\"null\"}]},\"optionalTextValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_text_value echoes proto3 optional string.\",\"examples\":[\"example\"]},\"optionalUint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_uint32_value echoes proto3 optional uint32.\",\"examples\":[1]},\"optionalUint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_uint64_value echoes proto3 optional uint64.\",\"examples\":[\"1\"]},\"samples\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"integer\",\"description\":\"samples echoes repeated plain scalar handling.\",\"examples\":[-1]},\"description\":\"samples echoes repeated plain scalar handling.\",\"examples\":[[-1]]},\"sfixed32Value\":{\"type\":\"integer\",\"description\":\"sfixed32_value echoes the plain sfixed32 kind.\",\"examples\":[-1]},\"sfixed64Value\":{\"type\":\"string\",\"description\":\"sfixed64_value echoes the plain sfixed64 kind.\",\"examples\":[\"-1\"]},\"sint32Value\":{\"type\":\"integer\",\"description\":\"sint32_value echoes the plain sint32 kind.\",\"examples\":[-1]},\"sint64Value\":{\"type\":\"string\",\"description\":\"sint64_value echoes the plain sint64 kind.\",\"examples\":[\"-1\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status echoes plain enum handling.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"textValue\":{\"type\":\"string\",\"description\":\"text_value echoes the plain string kind.\",\"examples\":[\"example\"]},\"uint32Value\":{\"type\":\"integer\",\"description\":\"uint32_value echoes the plain uint32 kind.\",\"examples\":[1]},\"uint64Value\":{\"type\":\"string\",\"description\":\"uint64_value echoes the plain uint64 kind.\",\"examples\":[\"1\"]}},\"description\":\"DescribeScalarShapesResponse echoes plain protobuf scalar kinds.\",\"examples\":[{\"boolFlag\":true,\"bytesValue\":\"aGVsbG8=\",\"details\":{\"label\":\"example\"},\"doubleValue\":1.25,\"fixed32Value\":1,\"fixed64Value\":\"1\",\"floatValue\":1.25,\"int32Value\":-1,\"int64Value\":\"-1\",\"optionalBoolFlag\":true,\"optionalBytesValue\":\"aGVsbG8=\",\"optionalDoubleValue\":1.25,\"optionalFixed32Value\":1,\"optionalFixed64Value\":\"1\",\"optionalFloatValue\":1.25,\"optionalInt32Value\":-1,\"optionalInt64Value\":\"-1\",\"optionalSfixed32Value\":-1,\"optionalSfixed64Value\":\"-1\",\"optionalSint32Value\":-1,\"optionalSint64Value\":\"-1\",\"optionalStatus\":\"REPORT_STATUS_OK\",\"optionalTextValue\":\"example\",\"optionalUint32Value\":1,\"optionalUint64Value\":\"1\",\"samples\":[-1],\"sfixed32Value\":-1,\"sfixed64Value\":\"-1\",\"sint32Value\":-1,\"sint64Value\":\"-1\",\"status\":\"REPORT_STATUS_OK\",\"textValue\":\"example\",\"uint32Value\":1,\"uint64Value\":\"1\"}],\"required\":[\"boolFlag\",\"textValue\",\"bytesValue\",\"int32Value\",\"sint32Value\",\"sfixed32Value\",\"uint32Value\",\"fixed32Value\",\"int64Value\",\"sint64Value\",\"sfixed64Value\",\"uint64Value\",\"fixed64Value\",\"floatValue\",\"doubleValue\",\"status\",\"details\"],\"additionalProperties\":false}"; + + private static final String EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"description\":\"name is the hidden request payload.\",\"examples\":[\"example\"]}},\"description\":\"HiddenThingRequest is used by the hidden RPC.\",\"deprecated\":true,\"examples\":[{\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}"; + + private static final String EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"description\":\"HiddenThingResponse is used by the hidden RPC.\",\"additionalProperties\":false}"; + + private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofMinutes(5); + private static final TypeRef> MAP_TYPE_REF = new TypeRef<>() { + }; + private static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); + private static final JsonSchemaValidator JSON_SCHEMA_VALIDATOR = McpJsonDefaults.getSchemaValidator(); + private static final Map SERVER_REGISTRIES = Collections.synchronizedMap(new WeakHashMap<>()); + + @FunctionalInterface + private interface ToolInvoker { + Message handle(McpAsyncServerExchange exchange, Message request) throws Exception; + } + + private static final class RegisteredTool { + private final String name; + private final String title; + private final String description; + private final Map inputSchema; + private final Map outputSchema; + private final Supplier requestBuilder; + private final Supplier responseBuilder; + private final ToolInvoker handler; + private final Map annotations; + private final List> icons; + private final Map execution; + + private RegisteredTool( + String name, + String title, + String description, + String inputSchemaJson, + String outputSchemaJson, + Supplier requestBuilder, + Supplier responseBuilder, + ToolInvoker handler, + Map annotations, + List> icons, + Map execution) { + this.name = name; + this.title = title; + this.description = description; + this.inputSchema = loadSchema(inputSchemaJson); + this.outputSchema = loadSchema(outputSchemaJson); + this.requestBuilder = requestBuilder; + this.responseBuilder = responseBuilder; + this.handler = handler; + this.annotations = annotations; + this.icons = icons; + this.execution = execution; + } + } + + private static final class ServerToolRegistry { + private final LinkedHashMap toolsByName = new LinkedHashMap<>(); + private boolean sessionFactoryInstalled; + + private synchronized void registerTool(RegisteredTool tool) { + if (this.toolsByName.containsKey(tool.name)) { + throw new IllegalStateException("duplicate tool registration: " + tool.name); + } + this.toolsByName.put(tool.name, tool); + } + + private synchronized RegisteredTool tool(String name) { + return this.toolsByName.get(name); + } + + private synchronized Collection allTools() { + return new ArrayList<>(this.toolsByName.values()); + } + + private synchronized boolean markSessionFactoryInstalled() { + if (this.sessionFactoryInstalled) { + return false; + } + this.sessionFactoryInstalled = true; + return true; + } + } + + private static ServerToolRegistry registryFor(McpServerTransportProvider transportProvider) { + synchronized (SERVER_REGISTRIES) { + ServerToolRegistry registry = SERVER_REGISTRIES.get(transportProvider); + if (registry == null) { + registry = new ServerToolRegistry(); + SERVER_REGISTRIES.put(transportProvider, registry); + } + return registry; + } + } + + private static void installSessionFactory(McpServerTransportProvider transportProvider, ServerToolRegistry registry) { + if (!registry.markSessionFactoryInstalled()) { + return; + } + transportProvider.setSessionFactory( + sessionTransport -> new McpServerSession( + UUID.randomUUID().toString(), + DEFAULT_REQUEST_TIMEOUT, + sessionTransport, + initializeHandler(transportProvider), + requestHandlers(registry), + notificationHandlers())); + } + + private static McpInitRequestHandler initializeHandler(McpServerTransportProvider transportProvider) { + return request -> Mono.just( + new McpSchema.InitializeResult( + resolveProtocolVersion(transportProvider), + new McpSchema.ServerCapabilities( + null, + null, + null, + null, + null, + new McpSchema.ServerCapabilities.ToolCapabilities(false)), + new McpSchema.Implementation("protoc-gen-mcp", "protoc-gen-mcp", "dev"), + null)); + } + + private static String resolveProtocolVersion(McpServerTransportProvider transportProvider) { + List protocolVersions = transportProvider.protocolVersions(); + if (protocolVersions == null || protocolVersions.isEmpty()) { + return "2024-11-05"; + } + return protocolVersions.get(0); + } + + private static Map> requestHandlers(ServerToolRegistry registry) { + Map> requestHandlers = new LinkedHashMap<>(); + requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, (exchange, params) -> Mono.just(listRegisteredTools(registry))); + requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, (exchange, params) -> dispatchToolCall(registry, exchange, params)); + return requestHandlers; + } + + private static Map notificationHandlers() { + return new LinkedHashMap<>(); + } + + private static Map listRegisteredTools(ServerToolRegistry registry) { + Map result = new LinkedHashMap<>(); + List> tools = new ArrayList<>(); + for (RegisteredTool tool : registry.allTools()) { + tools.add(toolAsProtocolMap(tool)); + } + result.put("tools", tools); + return result; + } + + private static Map toolAsProtocolMap(RegisteredTool tool) { + Map protocol = new LinkedHashMap<>(); + protocol.put("name", tool.name); + putIfNotNull(protocol, "title", tool.title); + putIfNotNull(protocol, "description", tool.description); + protocol.put("inputSchema", tool.inputSchema); + protocol.put("outputSchema", tool.outputSchema); + putIfNotNull(protocol, "annotations", tool.annotations); + if (!tool.icons.isEmpty()) { + protocol.put("icons", tool.icons); + } + putIfNotNull(protocol, "execution", tool.execution); + return protocol; + } + + private static void putIfNotNull(Map target, String key, Object value) { + if (value != null) { + target.put(key, value); + } + } + + private static Map toolAnnotationsMapOrNull( + String title, + Boolean readOnlyHint, + Boolean destructiveHint, + Boolean idempotentHint, + Boolean openWorldHint) { + if (title == null && readOnlyHint == null && destructiveHint == null && idempotentHint == null && openWorldHint == null) { + return null; + } + Map annotations = new LinkedHashMap<>(); + putIfNotNull(annotations, "title", title); + putIfNotNull(annotations, "readOnlyHint", readOnlyHint); + putIfNotNull(annotations, "destructiveHint", destructiveHint); + putIfNotNull(annotations, "idempotentHint", idempotentHint); + putIfNotNull(annotations, "openWorldHint", openWorldHint); + return annotations.isEmpty() ? null : annotations; + } + + private static Map toolExecutionMapOrNull(String taskSupport) { + if (taskSupport == null || taskSupport.isBlank()) { + return null; + } + Map execution = new LinkedHashMap<>(); + execution.put("taskSupport", taskSupport); + return execution; + } + + private static Map iconMap(String src, String mimeType, List sizes, String theme) { + Map icon = new LinkedHashMap<>(); + icon.put("src", src); + putIfNotNull(icon, "mimeType", mimeType); + if (sizes != null && !sizes.isEmpty()) { + icon.put("sizes", sizes); + } + putIfNotNull(icon, "theme", iconThemeOrError(theme)); + return icon; + } + + private static String iconThemeOrError(String rawTheme) { + if (rawTheme == null || rawTheme.isBlank()) { + return null; + } + if ("light".equals(rawTheme) || "dark".equals(rawTheme)) { + return rawTheme; + } + throw new IllegalArgumentException("unsupported Java icon theme \"" + rawTheme + "\""); + } + + private static Map loadSchema(String rawSchemaJson) { + try { + return JSON_MAPPER.readValue(rawSchemaJson, MAP_TYPE_REF); + } + catch (IOException error) { + throw new IllegalArgumentException("Invalid schema: " + rawSchemaJson, error); + } + } + + private static void validateJson(Map schema, Object payload) { + JsonSchemaValidator.ValidationResponse validation = JSON_SCHEMA_VALIDATOR.validate(schema, payload); + if (!validation.valid()) { + throw new IllegalArgumentException(validation.errorMessage()); + } + } + + private static Message parseProtoJson(String argumentsJson, Message.Builder builder) { + try { + JsonFormat.parser().merge(argumentsJson, builder); + return builder.build(); + } + catch (Exception error) { + throw new IllegalArgumentException(error.getMessage(), error); + } + } + + private static String marshalProtoJson(Message responseMessage) { + try { + return JsonFormat.printer() + .alwaysPrintFieldsWithNoPresence() + .omittingInsignificantWhitespace() + .print(responseMessage); + } + catch (Exception error) { + throw new IllegalStateException(error.getMessage(), error); + } + } + + private static Mono dispatchToolCall( + ServerToolRegistry registry, + McpAsyncServerExchange exchange, + Object params) { + final McpSchema.CallToolRequest request; + try { + request = JSON_MAPPER.convertValue(params, McpSchema.CallToolRequest.class); + } + catch (Exception error) { + return Mono.error(invalidParams("", error)); + } + + String requestedToolName = request.name() == null ? "" : request.name(); + RegisteredTool tool = registry.tool(requestedToolName); + if (tool == null) { + return Mono.error(invalidParams(requestedToolName, "unknown tool")); + } + + Map arguments = request.arguments() == null ? new LinkedHashMap<>() : request.arguments(); + try { + validateJson(tool.inputSchema, arguments); + } + catch (Exception error) { + return Mono.error(invalidParams(requestedToolName, error)); + } + + final Message requestMessage; + try { + requestMessage = parseProtoJson(JSON_MAPPER.writeValueAsString(arguments), tool.requestBuilder.get()); + } + catch (Exception error) { + return Mono.error(invalidParams(requestedToolName, error)); + } + + final Message responseMessage; + try { + Message handled = tool.handler.handle(exchange, requestMessage); + responseMessage = handled == null ? tool.responseBuilder.get().build() : handled; + } + catch (Exception error) { + String message = error.getMessage() == null ? error.toString() : error.getMessage(); + return Mono.just(toolErrorResult(message)); + } + + final String payload; + try { + payload = marshalProtoJson(responseMessage); + } + catch (Exception error) { + throw new IllegalStateException("mcpruntime: marshal output for tool '" + requestedToolName + "': " + error.getMessage(), error); + } + + final Map structuredContent; + try { + structuredContent = JSON_MAPPER.readValue(payload, MAP_TYPE_REF); + } + catch (IOException error) { + throw new IllegalStateException("mcpruntime: parse output for tool '" + requestedToolName + "': " + error.getMessage(), error); + } + + try { + validateJson(tool.outputSchema, structuredContent); + } + catch (Exception error) { + throw new IllegalStateException("mcpruntime: validate output for tool '" + requestedToolName + "': " + error.getMessage(), error); + } + + return Mono.just( + McpSchema.CallToolResult.builder() + .addTextContent(payload) + .structuredContent(structuredContent) + .build()); + } + + private static McpSchema.CallToolResult toolErrorResult(String message) { + return McpSchema.CallToolResult.builder() + .addTextContent(message) + .isError(true) + .build(); + } + + private static McpError invalidParams(String toolName, Object error) { + return McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("invalid arguments for tool '" + toolName + "': " + String.valueOf(error)) + .build(); + } + + private static String normalizeNamespace(String namespace, String defaultNamespace) { + return normalizeToolSegment(namespace == null ? defaultNamespace : namespace); + } + + private static String normalizeToolSegment(String segment) { + if (segment == null) { + return ""; + } + String trimmed = segment.trim().replace('.', '_'); + if (trimmed.isEmpty()) { + return ""; + } + String[] parts = trimmed.split("_"); + StringBuilder normalized = new StringBuilder(); + for (String part : parts) { + if (part == null || part.isBlank()) { + continue; + } + if (normalized.length() > 0) { + normalized.append('_'); + } + normalized.append(part); + } + return normalized.toString(); + } + + private static String toolName(String namespace, String methodName) { + String normalizedNamespace = normalizeToolSegment(namespace); + String normalizedMethodName = normalizeToolSegment(methodName); + if (normalizedNamespace.isEmpty()) { + return normalizedMethodName; + } + if (normalizedMethodName.isEmpty()) { + return normalizedNamespace; + } + return normalizedNamespace + "_" + normalizedMethodName; + } +} diff --git a/testdata/golden/example_mcp.kt.golden b/testdata/golden/example_mcp.kt.golden index 08f97f2..6b8412e 100644 --- a/testdata/golden/example_mcp.kt.golden +++ b/testdata/golden/example_mcp.kt.golden @@ -209,11 +209,11 @@ private fun toolName(namespace: String, methodName: String): String { } interface ExampleAPIToolHandler { - suspend fun createReport(ctx: ClientConnection, request: ExampleOuterClass.CreateReportRequest): ExampleOuterClass.CreateReportResponse - suspend fun ping(ctx: ClientConnection, request: ExampleOuterClass.PingRequest): ExampleOuterClass.PingResponse - suspend fun describeAdvancedShapes(ctx: ClientConnection, request: ExampleOuterClass.DescribeAdvancedShapesRequest): ExampleOuterClass.DescribeAdvancedShapesResponse - suspend fun describeScalarShapes(ctx: ClientConnection, request: ExampleOuterClass.DescribeScalarShapesRequest): ExampleOuterClass.DescribeScalarShapesResponse - suspend fun hiddenThing(ctx: ClientConnection, request: ExampleOuterClass.HiddenThingRequest): ExampleOuterClass.HiddenThingResponse + suspend fun createReport(ctx: ClientConnection, request: Example.CreateReportRequest): Example.CreateReportResponse + suspend fun ping(ctx: ClientConnection, request: Example.PingRequest): Example.PingResponse + suspend fun describeAdvancedShapes(ctx: ClientConnection, request: Example.DescribeAdvancedShapesRequest): Example.DescribeAdvancedShapesResponse + suspend fun describeScalarShapes(ctx: ClientConnection, request: Example.DescribeScalarShapesRequest): Example.DescribeScalarShapesResponse + suspend fun hiddenThing(ctx: ClientConnection, request: Example.HiddenThingRequest): Example.HiddenThingResponse } fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespace: String? = null) { @@ -226,8 +226,8 @@ fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespa description = "Create a report for a city.", inputSchemaJson = EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, outputSchemaJson = EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, - requestBuilder = { ExampleOuterClass.CreateReportRequest.newBuilder() }, - handler = { ctx, request -> impl.createReport(ctx, request as ExampleOuterClass.CreateReportRequest) as Message }, + requestBuilder = { Example.CreateReportRequest.newBuilder() }, + handler = { ctx, request -> impl.createReport(ctx, request as Example.CreateReportRequest) as Message }, annotations = null, icons = emptyList(), execution = toolExecutionOrNull("none"), @@ -240,8 +240,8 @@ fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespa description = "Ping returns an empty response.", inputSchemaJson = EXAMPLE_API_PING_INPUT_SCHEMA_JSON, outputSchemaJson = EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON, - requestBuilder = { ExampleOuterClass.PingRequest.newBuilder() }, - handler = { ctx, request -> impl.ping(ctx, request as ExampleOuterClass.PingRequest) as Message }, + requestBuilder = { Example.PingRequest.newBuilder() }, + handler = { ctx, request -> impl.ping(ctx, request as Example.PingRequest) as Message }, annotations = null, icons = emptyList(), execution = toolExecutionOrNull("none"), @@ -254,8 +254,8 @@ fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespa description = "Exercise maps and well-known protobuf types.", inputSchemaJson = EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON, outputSchemaJson = EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON, - requestBuilder = { ExampleOuterClass.DescribeAdvancedShapesRequest.newBuilder() }, - handler = { ctx, request -> impl.describeAdvancedShapes(ctx, request as ExampleOuterClass.DescribeAdvancedShapesRequest) as Message }, + requestBuilder = { Example.DescribeAdvancedShapesRequest.newBuilder() }, + handler = { ctx, request -> impl.describeAdvancedShapes(ctx, request as Example.DescribeAdvancedShapesRequest) as Message }, annotations = null, icons = emptyList(), execution = toolExecutionOrNull("none"), @@ -268,8 +268,8 @@ fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespa description = "Exercise plain protobuf scalar kinds.", inputSchemaJson = EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON, outputSchemaJson = EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON, - requestBuilder = { ExampleOuterClass.DescribeScalarShapesRequest.newBuilder() }, - handler = { ctx, request -> impl.describeScalarShapes(ctx, request as ExampleOuterClass.DescribeScalarShapesRequest) as Message }, + requestBuilder = { Example.DescribeScalarShapesRequest.newBuilder() }, + handler = { ctx, request -> impl.describeScalarShapes(ctx, request as Example.DescribeScalarShapesRequest) as Message }, annotations = null, icons = emptyList(), execution = toolExecutionOrNull("none"), @@ -282,8 +282,8 @@ fun registerExampleAPITools(server: Server, impl: ExampleAPIToolHandler, namespa description = "HiddenThing is intentionally hidden from generated tools.\nNote: the `hidden` method option was removed in this iteration.", inputSchemaJson = EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON, outputSchemaJson = EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON, - requestBuilder = { ExampleOuterClass.HiddenThingRequest.newBuilder() }, - handler = { ctx, request -> impl.hiddenThing(ctx, request as ExampleOuterClass.HiddenThingRequest) as Message }, + requestBuilder = { Example.HiddenThingRequest.newBuilder() }, + handler = { ctx, request -> impl.hiddenThing(ctx, request as Example.HiddenThingRequest) as Message }, annotations = null, icons = emptyList(), execution = toolExecutionOrNull("none"), From ea96f6835234dc78d5df5c003ba3ad4d329b4171 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 22:43:35 +0300 Subject: [PATCH 17/74] test(04-01): add failing Kotlin SDK and schema contract coverage - assert public server client connection usage in generated Kotlin - require networknt validation and escaped schema dollar keys --- internal/codegen/kotlin_contract_test.go | 44 ++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/internal/codegen/kotlin_contract_test.go b/internal/codegen/kotlin_contract_test.go index 6531ff1..9dc00d7 100644 --- a/internal/codegen/kotlin_contract_test.go +++ b/internal/codegen/kotlin_contract_test.go @@ -97,10 +97,10 @@ func TestKotlinContract_JavaPackageAndWrapperImports(t *testing.T) { "import com.example.shared.SharedTypes", "import com.example.sharedmulti.MultiRequest", "import com.example.sharedmulti.MultiResponse", - "import com.example.defaultouter.SharedDefaultOuterOuterClass", + "import com.example.defaultouter.SharedDefaultOuter", "suspend fun useShared(ctx: ClientConnection, request: SharedTypes.SharedRequest): SharedTypes.SharedResponse", "suspend fun useMulti(ctx: ClientConnection, request: MultiRequest): MultiResponse", - "suspend fun useDefault(ctx: ClientConnection, request: SharedDefaultOuterOuterClass.DefaultRequest): SharedDefaultOuterOuterClass.DefaultResponse", + "suspend fun useDefault(ctx: ClientConnection, request: SharedDefaultOuter.DefaultRequest): SharedDefaultOuter.DefaultResponse", } assertKotlinContains(t, generated, wantSnippets...) } @@ -120,6 +120,35 @@ func TestKotlinContract_RawSchemaProjectionAndValidation(t *testing.T) { assertKotlinContains(t, generated, wantSnippets...) } +func TestKotlinContract_UsesPublicServerClientConnection(t *testing.T) { + generated := renderBasicKotlinFixture(t) + + assertKotlinContains(t, generated, "server.clientConnection(session.sessionId)") + assertKotlinOmits(t, generated, "session.clientConnection") +} + +func TestKotlinContract_UsesNetworkntJsonSchemaValidation(t *testing.T) { + generated := renderBasicKotlinFixture(t) + + wantSnippets := []string{ + "import com.networknt.schema.InputFormat", + "import com.networknt.schema.JsonSchemaFactory", + "import com.networknt.schema.SpecVersion", + "private val jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)", + "jsonSchemaFactory.getSchema(rawSchemaJson).validate(payload.toString(), InputFormat.JSON)", + `invalidParams(tool.name, error.message ?: error.toString())`, + `IllegalStateException("mcpruntime: validate output for tool '${tool.name}': ${error.message}", error)`, + } + assertKotlinContains(t, generated, wantSnippets...) +} + +func TestKotlinContract_EscapesSchemaDollarSigns(t *testing.T) { + generated := renderRepositoryExampleKotlinFixture(t) + + assertKotlinContains(t, generated, `\$ref`, `\$defs`) + assertKotlinOmits(t, generated, `\"$ref\"`, `\"$defs\"`) +} + func TestKotlinContract_AnnotationsIconsAndThemeValidation(t *testing.T) { plugin := newTempProtogenPlugin(t, map[string]string{ "test/v1/metadata.proto": strings.Join([]string{ @@ -232,6 +261,17 @@ func renderBasicKotlinFixture(t *testing.T) string { return renderKotlinFileFromPlugin(t, plugin, "test/v1/example.proto") } +func renderRepositoryExampleKotlinFixture(t *testing.T) string { + t.Helper() + + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: LanguageKotlin}); err != nil { + t.Fatalf("Generate: %v", err) + } + + return string(generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp.kt")) +} + func renderKotlinFileFromPlugin(t *testing.T, plugin *protogen.Plugin, protoPath string) string { t.Helper() From 90361120d504dbf7661b4a85fa4000132997defa Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 22:46:11 +0300 Subject: [PATCH 18/74] fix(04-01): harden generated Kotlin schema handling - use public server client connection lookup in low-level Kotlin handlers - add networknt raw JSON Schema validation and escape dollar keys in schema constants --- internal/codegen/render_kotlin.go | 43 ++++++++++++++++++--------- testdata/golden/example_mcp.kt.golden | 39 +++++++++++++++--------- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/internal/codegen/render_kotlin.go b/internal/codegen/render_kotlin.go index b424754..cc11bcf 100644 --- a/internal/codegen/render_kotlin.go +++ b/internal/codegen/render_kotlin.go @@ -51,6 +51,9 @@ func renderKotlinFile(plugin *protogen.Plugin, model JVMFileModel) error { } kotlinImports := []string{ + "com.networknt.schema.InputFormat", + "com.networknt.schema.JsonSchemaFactory", + "com.networknt.schema.SpecVersion", "com.google.protobuf.Message", "com.google.protobuf.util.JsonFormat", "io.modelcontextprotocol.kotlin.sdk.server.ClientConnection", @@ -141,9 +144,9 @@ func renderKotlinFile(plugin *protogen.Plugin, model JVMFileModel) error { generated.P() for methodIdx, method := range service.Methods { - generated.P("private const val ", method.SchemaConst, "_INPUT_SCHEMA_JSON = ", quote(method.InputSchemaJSON)) + generated.P("private const val ", method.SchemaConst, "_INPUT_SCHEMA_JSON = ", kotlinStringLiteral(method.InputSchemaJSON)) generated.P() - generated.P("private const val ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON = ", quote(method.OutputSchemaJSON)) + generated.P("private const val ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON = ", kotlinStringLiteral(method.OutputSchemaJSON)) if methodIdx < len(service.Methods)-1 || serviceIdx < len(model.Services)-1 { generated.P() } @@ -229,6 +232,7 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P("}") generated.P() generated.P("private val serverRegistries = WeakHashMap()") + generated.P("private val jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)") generated.P() generated.P("private fun installMcpHandlers(server: Server): ServerToolRegistry {") generated.P(" val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() }") @@ -251,7 +255,7 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P(" listRegisteredTools(registry, request)") generated.P(" }") generated.P(" session.setRequestHandler(Method.Defined.ToolsCall) { request: CallToolRequest, _ ->") - generated.P(" dispatchToolCall(registry, session.clientConnection, request)") + generated.P(" dispatchToolCall(registry, server.clientConnection(session.sessionId), request)") generated.P(" }") generated.P(" }") generated.P("}") @@ -263,15 +267,27 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P("private suspend fun dispatchToolCall(registry: ServerToolRegistry, ctx: ClientConnection, request: CallToolRequest): CallToolResult {") generated.P(" val tool = registry.tool(request.params.name) ?: invalidParams(request.params.name, \"unknown tool\")") generated.P(" val arguments = request.params.arguments ?: buildJsonObject {}") - generated.P(" validateJson(tool.inputSchemaJson, arguments)") - generated.P(" val requestMessage = parseProtoJson(arguments.toString(), tool.requestBuilder())") + generated.P(" try {") + generated.P(" validateJson(tool.inputSchemaJson, arguments)") + generated.P(" } catch (error: Exception) {") + generated.P(" invalidParams(tool.name, error.message ?: error.toString())") + generated.P(" }") + generated.P(" val requestMessage = try {") + generated.P(" parseProtoJson(arguments.toString(), tool.requestBuilder())") + generated.P(" } catch (error: Exception) {") + generated.P(" invalidParams(tool.name, error.message ?: error.toString())") + generated.P(" }") generated.P(" val responseMessage = try {") generated.P(" tool.handler(ctx, requestMessage)") generated.P(" } catch (error: Exception) {") generated.P(" return CallToolResult(content = listOf(TextContent(text = error.message ?: error.toString())), isError = true)") generated.P(" }") generated.P(" val payload = marshalProtoJson(responseMessage)") - generated.P(" validateJson(tool.outputSchemaJson, payload)") + generated.P(" try {") + generated.P(" validateJson(tool.outputSchemaJson, payload)") + generated.P(" } catch (error: Exception) {") + generated.P(" throw IllegalStateException(\"mcpruntime: validate output for tool '${tool.name}': ${error.message}\", error)") + generated.P(" }") generated.P(" val textPayload = payload.toString()") generated.P(" return CallToolResult(content = listOf(TextContent(text = textPayload)), structuredContent = payload)") generated.P("}") @@ -312,14 +328,9 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P("}") generated.P() generated.P("private fun validateJson(rawSchemaJson: String, payload: JsonElement) {") - generated.P(" val schema = loadSchema(rawSchemaJson)") - generated.P(" val required = schema[\"required\"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()") - generated.P(" if (payload !is JsonObject) {") - generated.P(" invalidParams(\"\", \"payload must be a JSON object\")") - generated.P(" }") - generated.P(" val missing = required.filterNot { payload.containsKey(it) }") - generated.P(" if (missing.isNotEmpty()) {") - generated.P(" invalidParams(\"\", \"missing required fields: ${missing.joinToString(\", \")}\")") + generated.P(" val errors = jsonSchemaFactory.getSchema(rawSchemaJson).validate(payload.toString(), InputFormat.JSON)") + generated.P(" if (errors.isNotEmpty()) {") + generated.P(" throw IllegalArgumentException(errors.joinToString(\"; \") { it.message })") generated.P(" }") generated.P("}") generated.P() @@ -476,6 +487,10 @@ func kotlinNullableString(value string) string { return quote(value) } +func kotlinStringLiteral(value string) string { + return strings.ReplaceAll(quote(value), "$", `\$`) +} + func kotlinImportPackage(importPath string) string { idx := strings.LastIndex(importPath, ".") if idx <= 0 { diff --git a/testdata/golden/example_mcp.kt.golden b/testdata/golden/example_mcp.kt.golden index 6b8412e..f76f4eb 100644 --- a/testdata/golden/example_mcp.kt.golden +++ b/testdata/golden/example_mcp.kt.golden @@ -5,6 +5,9 @@ package internal.testproto.example.v1 import com.google.protobuf.Message import com.google.protobuf.util.JsonFormat +import com.networknt.schema.InputFormat +import com.networknt.schema.JsonSchemaFactory +import com.networknt.schema.SpecVersion import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest @@ -61,6 +64,7 @@ private class ServerToolRegistry { } private val serverRegistries = WeakHashMap() +private val jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) private fun installMcpHandlers(server: Server): ServerToolRegistry { val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() } @@ -83,7 +87,7 @@ private fun installSessionHandlers(server: Server, registry: ServerToolRegistry) listRegisteredTools(registry, request) } session.setRequestHandler(Method.Defined.ToolsCall) { request: CallToolRequest, _ -> - dispatchToolCall(registry, session.clientConnection, request) + dispatchToolCall(registry, server.clientConnection(session.sessionId), request) } } } @@ -95,15 +99,27 @@ private suspend fun listRegisteredTools(registry: ServerToolRegistry, request: L private suspend fun dispatchToolCall(registry: ServerToolRegistry, ctx: ClientConnection, request: CallToolRequest): CallToolResult { val tool = registry.tool(request.params.name) ?: invalidParams(request.params.name, "unknown tool") val arguments = request.params.arguments ?: buildJsonObject {} - validateJson(tool.inputSchemaJson, arguments) - val requestMessage = parseProtoJson(arguments.toString(), tool.requestBuilder()) + try { + validateJson(tool.inputSchemaJson, arguments) + } catch (error: Exception) { + invalidParams(tool.name, error.message ?: error.toString()) + } + val requestMessage = try { + parseProtoJson(arguments.toString(), tool.requestBuilder()) + } catch (error: Exception) { + invalidParams(tool.name, error.message ?: error.toString()) + } val responseMessage = try { tool.handler(ctx, requestMessage) } catch (error: Exception) { return CallToolResult(content = listOf(TextContent(text = error.message ?: error.toString())), isError = true) } val payload = marshalProtoJson(responseMessage) - validateJson(tool.outputSchemaJson, payload) + try { + validateJson(tool.outputSchemaJson, payload) + } catch (error: Exception) { + throw IllegalStateException("mcpruntime: validate output for tool '${tool.name}': ${error.message}", error) + } val textPayload = payload.toString() return CallToolResult(content = listOf(TextContent(text = textPayload)), structuredContent = payload) } @@ -144,14 +160,9 @@ private fun projectToolSchema(rawSchemaJson: String): ToolSchema { } private fun validateJson(rawSchemaJson: String, payload: JsonElement) { - val schema = loadSchema(rawSchemaJson) - val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty() - if (payload !is JsonObject) { - invalidParams("", "payload must be a JSON object") - } - val missing = required.filterNot { payload.containsKey(it) } - if (missing.isNotEmpty()) { - invalidParams("", "missing required fields: ${missing.joinToString(", ")}") + val errors = jsonSchemaFactory.getSchema(rawSchemaJson).validate(payload.toString(), InputFormat.JSON) + if (errors.isNotEmpty()) { + throw IllegalArgumentException(errors.joinToString("; ") { it.message }) } } @@ -299,9 +310,9 @@ private const val EXAMPLE_API_PING_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"d private const val EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"ack\":{\"type\":\"object\",\"description\":\"ack confirms the health-check call.\",\"examples\":[{}],\"additionalProperties\":false}},\"description\":\"PingResponse is used by the health-check RPC.\",\"examples\":[{\"ack\":{}}],\"required\":[\"ack\"],\"additionalProperties\":false}" -private const val EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesRequest exercises supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}" +private const val EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)\$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)\$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"\$ref\":\"#/\$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"\$ref\":\"#/\$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s\$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"\$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"\$ref\":\"#/\$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"\$ref\":\"#/\$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesRequest exercises supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}" -private const val EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesResponse echoes supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}" +private const val EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)\$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)\$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"\$ref\":\"#/\$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"\$ref\":\"#/\$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s\$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"\$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"\$ref\":\"#/\$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"\$ref\":\"#/\$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesResponse echoes supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}" private const val EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"boolFlag\":{\"type\":\"boolean\",\"description\":\"bool_flag exercises the plain bool kind.\",\"examples\":[true]},\"bytesValue\":{\"type\":\"string\",\"description\":\"bytes_value exercises the plain bytes kind.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details exercises plain nested message handling.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"doubleValue\":{\"description\":\"double_value exercises the plain double kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"fixed32Value\":{\"type\":\"integer\",\"description\":\"fixed32_value exercises the plain fixed32 kind.\",\"examples\":[1]},\"fixed64Value\":{\"type\":\"string\",\"description\":\"fixed64_value exercises the plain fixed64 kind.\",\"examples\":[\"1\"]},\"floatValue\":{\"description\":\"float_value exercises the plain float kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"int32Value\":{\"type\":\"integer\",\"description\":\"int32_value exercises the plain int32 kind.\",\"examples\":[-1]},\"int64Value\":{\"type\":\"string\",\"description\":\"int64_value exercises the plain int64 kind.\",\"examples\":[\"-1\"]},\"optionalBoolFlag\":{\"type\":[\"boolean\",\"null\"],\"description\":\"optional_bool_flag exercises proto3 optional bool.\",\"examples\":[true]},\"optionalBytesValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_bytes_value exercises proto3 optional bytes.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"optionalDoubleValue\":{\"description\":\"optional_double_value exercises proto3 optional double.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalFixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_fixed32_value exercises proto3 optional fixed32.\",\"examples\":[1]},\"optionalFixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_fixed64_value exercises proto3 optional fixed64.\",\"examples\":[\"1\"]},\"optionalFloatValue\":{\"description\":\"optional_float_value exercises proto3 optional float.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalInt32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_int32_value exercises proto3 optional int32.\",\"examples\":[-1]},\"optionalInt64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_int64_value exercises proto3 optional int64.\",\"examples\":[\"-1\"]},\"optionalSfixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sfixed32_value exercises proto3 optional sfixed32.\",\"examples\":[-1]},\"optionalSfixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sfixed64_value exercises proto3 optional sfixed64.\",\"examples\":[\"-1\"]},\"optionalSint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sint32_value exercises proto3 optional sint32.\",\"examples\":[-1]},\"optionalSint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sint64_value exercises proto3 optional sint64.\",\"examples\":[\"-1\"]},\"optionalStatus\":{\"description\":\"optional_status exercises proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"anyOf\":[{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"optional_status exercises proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},{\"type\":\"null\"}]},\"optionalTextValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_text_value exercises proto3 optional string.\",\"examples\":[\"example\"]},\"optionalUint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_uint32_value exercises proto3 optional uint32.\",\"examples\":[1]},\"optionalUint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_uint64_value exercises proto3 optional uint64.\",\"examples\":[\"1\"]},\"samples\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"integer\",\"description\":\"samples exercises repeated plain scalar handling.\",\"examples\":[-1]},\"description\":\"samples exercises repeated plain scalar handling.\",\"examples\":[[-1]]},\"sfixed32Value\":{\"type\":\"integer\",\"description\":\"sfixed32_value exercises the plain sfixed32 kind.\",\"examples\":[-1]},\"sfixed64Value\":{\"type\":\"string\",\"description\":\"sfixed64_value exercises the plain sfixed64 kind.\",\"examples\":[\"-1\"]},\"sint32Value\":{\"type\":\"integer\",\"description\":\"sint32_value exercises the plain sint32 kind.\",\"examples\":[-1]},\"sint64Value\":{\"type\":\"string\",\"description\":\"sint64_value exercises the plain sint64 kind.\",\"examples\":[\"-1\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status exercises plain enum handling.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"textValue\":{\"type\":\"string\",\"description\":\"text_value exercises the plain string kind.\",\"examples\":[\"example\"]},\"uint32Value\":{\"type\":\"integer\",\"description\":\"uint32_value exercises the plain uint32 kind.\",\"examples\":[1]},\"uint64Value\":{\"type\":\"string\",\"description\":\"uint64_value exercises the plain uint64 kind.\",\"examples\":[\"1\"]}},\"description\":\"DescribeScalarShapesRequest exercises plain protobuf scalar kinds.\",\"examples\":[{\"boolFlag\":true,\"bytesValue\":\"aGVsbG8=\",\"details\":{\"label\":\"example\"},\"doubleValue\":1.25,\"fixed32Value\":1,\"fixed64Value\":\"1\",\"floatValue\":1.25,\"int32Value\":-1,\"int64Value\":\"-1\",\"optionalBoolFlag\":true,\"optionalBytesValue\":\"aGVsbG8=\",\"optionalDoubleValue\":1.25,\"optionalFixed32Value\":1,\"optionalFixed64Value\":\"1\",\"optionalFloatValue\":1.25,\"optionalInt32Value\":-1,\"optionalInt64Value\":\"-1\",\"optionalSfixed32Value\":-1,\"optionalSfixed64Value\":\"-1\",\"optionalSint32Value\":-1,\"optionalSint64Value\":\"-1\",\"optionalStatus\":\"REPORT_STATUS_OK\",\"optionalTextValue\":\"example\",\"optionalUint32Value\":1,\"optionalUint64Value\":\"1\",\"samples\":[-1],\"sfixed32Value\":-1,\"sfixed64Value\":\"-1\",\"sint32Value\":-1,\"sint64Value\":\"-1\",\"status\":\"REPORT_STATUS_OK\",\"textValue\":\"example\",\"uint32Value\":1,\"uint64Value\":\"1\"}],\"required\":[\"boolFlag\",\"textValue\",\"bytesValue\",\"int32Value\",\"sint32Value\",\"sfixed32Value\",\"uint32Value\",\"fixed32Value\",\"int64Value\",\"sint64Value\",\"sfixed64Value\",\"uint64Value\",\"fixed64Value\",\"floatValue\",\"doubleValue\",\"status\",\"details\"],\"additionalProperties\":false}" From 608a43ac02f5e90c5fb6b180866283299a18d5b9 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 15 Apr 2026 22:55:35 +0300 Subject: [PATCH 19/74] feat(04-01): add JVM Gradle compile gate foundation - add isolated examples/jvm workspace that builds the local protoc-gen-mcp binary - compile generated Java and Kotlin sidecars with Maven protoc and official SDK artifacts --- examples/jvm/build.gradle.kts | 23 ++++++ examples/jvm/java-server/build.gradle.kts | 69 +++++++++++++++++ examples/jvm/kotlin-server/build.gradle.kts | 82 +++++++++++++++++++++ examples/jvm/settings.gradle.kts | 21 ++++++ 4 files changed, 195 insertions(+) create mode 100644 examples/jvm/build.gradle.kts create mode 100644 examples/jvm/java-server/build.gradle.kts create mode 100644 examples/jvm/kotlin-server/build.gradle.kts create mode 100644 examples/jvm/settings.gradle.kts diff --git a/examples/jvm/build.gradle.kts b/examples/jvm/build.gradle.kts new file mode 100644 index 0000000..953410d --- /dev/null +++ b/examples/jvm/build.gradle.kts @@ -0,0 +1,23 @@ +import org.gradle.api.tasks.Exec + +plugins { + id("com.google.protobuf") version "0.9.6" apply false + id("org.jetbrains.kotlin.jvm") version "2.3.10" apply false +} + +val repoRoot = rootDir.resolve("../..").canonicalFile +val jvmBuildRoot = repoRoot.parentFile.resolve(".protoc-gen-mcp-jvm-build").canonicalFile +val protocGenMcpBinary = layout.buildDirectory.file("tools/protoc-gen-mcp") + +layout.buildDirectory.set(jvmBuildRoot.resolve("root")) + +tasks.register("buildProtocGenMcp") { + group = "build" + description = "Build the local protoc-gen-mcp binary used by JVM compile gates." + outputs.file(protocGenMcpBinary) + doFirst { + protocGenMcpBinary.get().asFile.parentFile.mkdirs() + } + workingDir = repoRoot + commandLine("go", "build", "-o", protocGenMcpBinary.get().asFile.absolutePath, "./cmd/protoc-gen-mcp") +} diff --git a/examples/jvm/java-server/build.gradle.kts b/examples/jvm/java-server/build.gradle.kts new file mode 100644 index 0000000..2b97b01 --- /dev/null +++ b/examples/jvm/java-server/build.gradle.kts @@ -0,0 +1,69 @@ +import com.google.protobuf.gradle.* +import org.gradle.api.tasks.Sync + +plugins { + java + id("com.google.protobuf") +} + +dependencies { + implementation("io.modelcontextprotocol.sdk:mcp:1.1.1") + implementation("com.google.protobuf:protobuf-java:4.34.1") + implementation("com.google.protobuf:protobuf-java-util:4.34.1") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +val repoRoot = rootProject.projectDir.resolve("../..").canonicalFile +layout.buildDirectory.set(repoRoot.parentFile.resolve(".protoc-gen-mcp-jvm-build").resolve(project.name)) +val protocGenMcpBinary = rootProject.layout.buildDirectory.file("tools/protoc-gen-mcp") +val stagedProtoDir = layout.buildDirectory.dir("staged-proto") +val stageProtoSources = tasks.register("stageProtoSources") { + from(repoRoot) { + include("internal/testproto/example/v1/example.proto") + include("mcp/options/v1/options.proto") + } + into(stagedProtoDir) +} + +sourceSets { + named("main") { + proto { + srcDir(stagedProtoDir) + } + java { + srcDir(layout.buildDirectory.dir("generated/source/proto/main/java")) + srcDir(layout.buildDirectory.dir("generated/source/proto/main/mcp")) + } + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.34.1" + } + plugins { + id("mcp") { + path = protocGenMcpBinary.get().asFile.absolutePath + } + } + generateProtoTasks { + ofSourceSet("main").configureEach { + dependsOn(rootProject.tasks.named("buildProtocGenMcp")) + dependsOn(stageProtoSources) + plugins { + id("mcp") { + option("paths=source_relative") + option("lang=java") + } + } + } + } +} + +tasks.named("compileJava") { + dependsOn(tasks.named("generateProto")) +} diff --git a/examples/jvm/kotlin-server/build.gradle.kts b/examples/jvm/kotlin-server/build.gradle.kts new file mode 100644 index 0000000..ae108ee --- /dev/null +++ b/examples/jvm/kotlin-server/build.gradle.kts @@ -0,0 +1,82 @@ +import com.google.protobuf.gradle.* +import org.gradle.api.tasks.Sync + +plugins { + id("org.jetbrains.kotlin.jvm") + java + id("com.google.protobuf") +} + +dependencies { + implementation("io.modelcontextprotocol:kotlin-sdk-server:0.11.1") + implementation("com.google.protobuf:protobuf-java:4.34.1") + implementation("com.google.protobuf:protobuf-kotlin:4.34.1") + implementation("com.google.protobuf:protobuf-java-util:4.34.1") + implementation("com.networknt:json-schema-validator:1.5.9") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +val repoRoot = rootProject.projectDir.resolve("../..").canonicalFile +layout.buildDirectory.set(repoRoot.parentFile.resolve(".protoc-gen-mcp-jvm-build").resolve(project.name)) +val protocGenMcpBinary = rootProject.layout.buildDirectory.file("tools/protoc-gen-mcp") +val stagedProtoDir = layout.buildDirectory.dir("staged-proto") +val stageProtoSources = tasks.register("stageProtoSources") { + from(repoRoot) { + include("internal/testproto/example/v1/example.proto") + include("mcp/options/v1/options.proto") + } + into(stagedProtoDir) +} + +sourceSets { + named("main") { + proto { + srcDir(stagedProtoDir) + } + java { + srcDir(layout.buildDirectory.dir("generated/source/proto/main/java")) + } + } +} + +kotlin { + jvmToolchain(17) + sourceSets.named("main") { + kotlin.srcDir(layout.buildDirectory.dir("generated/source/proto/main/kotlin")) + kotlin.srcDir(layout.buildDirectory.dir("generated/source/proto/main/mcp")) + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.34.1" + } + plugins { + id("mcp") { + path = protocGenMcpBinary.get().asFile.absolutePath + } + } + generateProtoTasks { + ofSourceSet("main").configureEach { + dependsOn(rootProject.tasks.named("buildProtocGenMcp")) + dependsOn(stageProtoSources) + builtins { + id("kotlin") {} + } + plugins { + id("mcp") { + option("paths=source_relative") + option("lang=kotlin") + } + } + } + } +} + +tasks.named("compileKotlin") { + dependsOn(tasks.named("generateProto")) +} diff --git a/examples/jvm/settings.gradle.kts b/examples/jvm/settings.gradle.kts new file mode 100644 index 0000000..2510cf9 --- /dev/null +++ b/examples/jvm/settings.gradle.kts @@ -0,0 +1,21 @@ +import org.gradle.api.initialization.resolve.RepositoriesMode + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + google() + } +} + +rootProject.name = "protoc-gen-mcp-jvm-examples" + +include("java-server", "kotlin-server") From 974185b85ab76382ffadd7b2e52bf801d9b41427 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sun, 19 Apr 2026 19:47:40 +0300 Subject: [PATCH 20/74] feat: add Java and Kotlin MCP server generation --- .github/workflows/tests.yml | 21 + .gitignore | 2 + AGENTS.md | 110 ++- README.md | 72 +- examples/README.md | 25 +- examples/jvm/README.md | 107 +++ examples/jvm/java-server/build.gradle.kts | 10 +- .../examplemcp/java/ExampleJavaServer.java | 234 +++++ examples/jvm/kotlin-server/build.gradle.kts | 11 +- .../examplemcp/kotlin/ExampleKotlinServer.kt | 250 +++++ internal/codegen/generator.go | 42 +- internal/codegen/generator_test.go | 667 ++++++++++++- internal/codegen/java_contract_test.go | 298 ++++++ internal/codegen/kotlin_contract_test.go | 9 +- internal/codegen/render_java.go | 892 ++++++++++++++++++ internal/codegen/render_kotlin.go | 54 +- internal/examplemcp/jvm_stdio_test.go | 167 ++++ testdata/golden/example_mcp.java.golden | 35 +- testdata/golden/example_mcp.kt.golden | 39 +- 19 files changed, 2998 insertions(+), 47 deletions(-) create mode 100644 examples/jvm/README.md create mode 100644 examples/jvm/java-server/src/main/java/internal/examplemcp/java/ExampleJavaServer.java create mode 100644 examples/jvm/kotlin-server/src/main/kotlin/internal/examplemcp/kotlin/ExampleKotlinServer.kt create mode 100644 internal/codegen/java_contract_test.go create mode 100644 internal/codegen/render_java.go create mode 100644 internal/examplemcp/jvm_stdio_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 96bc982..89002de 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,6 +25,17 @@ jobs: with: python-version: "3.10" + - name: Install JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Install Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "9.2.1" + - name: Install Python runtime dependencies run: | python -m pip install --upgrade pip @@ -60,5 +71,15 @@ jobs: - name: Verify generated files are up to date run: git diff --exit-code + - name: Build JVM examples + run: | + gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin + gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist + + - name: Run JVM stdio tests + run: | + go test ./internal/examplemcp -run 'TestJava.*OverStdio' -count=1 + go test ./internal/examplemcp -run 'TestKotlin.*OverStdio' -count=1 + - name: Run tests run: go test ./... diff --git a/.gitignore b/.gitignore index c611082..eeda822 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,6 @@ examples/1_helloworld/helloworld-server examples/2_weather_api/weather-server examples/3_file_manager/file-server examples/4_crm_system/crm-server +examples/jvm/.gradle/ +examples/jvm/*/build/ .DS_Store diff --git a/AGENTS.md b/AGENTS.md index db46b8f..5f69ed9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,19 +3,21 @@ ## Scope This repository implements a protobuf-first MCP generator and runtime for Go, -Python, and Kotlin MCP server bindings. The MVP is intentionally narrow and -must stay decision-consistent with the current architecture unless explicitly -revised. +Python, Kotlin, and Java MCP server bindings. The MVP is intentionally narrow +and must stay decision-consistent with the current architecture unless +explicitly revised. ## Stack - Go 1.24+ - Python 3.10+ for generated-runtime verification and example servers +- Gradle 9.2+ and JDK 17+ for JVM compile-gate verification - `easyp v0.15.2-rc1` for repository linting and code generation workflows - `google.golang.org/protobuf` for code generation, reflection, and ProtoJSON - `google.protobuf` for Python generated modules and ProtoJSON conversion - `github.com/modelcontextprotocol/go-sdk/mcp` as the MCP runtime - `mcp>=1.27,<2` as the official Python MCP SDK target +- `io.modelcontextprotocol.sdk:mcp` as the official Java MCP SDK target - `io.modelcontextprotocol:kotlin-sdk-server` as the official Kotlin MCP SDK target - `github.com/google/jsonschema-go/jsonschema` for JSON Schema parsing and @@ -31,9 +33,10 @@ revised. - `mcpruntime`: public runtime helpers used by generated code - `.github/workflows`: GitHub Actions CI and release workflows - `.goreleaser.yaml`: release packaging for the plugin binary -- `examples`: standalone Go/Python integration projects; example directories - use numeric underscore prefixes such as `1_helloworld`, `4_crm_system`, and - `5_python_standalone` +- `examples`: standalone Go/Python/JVM integration projects; example + directories use numeric underscore prefixes such as `1_helloworld`, + `4_crm_system`, and `5_python_standalone`, plus the dedicated JVM workspace + `examples/jvm` - `examples/easyp.lock`: pinned Easyp dependency lock for standalone examples - `examples/mcp`: generated Python `mcp.options.*` protobuf modules for standalone examples; generated from the GitHub dependency declared in @@ -41,12 +44,31 @@ revised. - `examples/5_python_standalone`: Python-only user-style example with its own `pyproject.toml`, `easyp.yaml`, generated `proto`/`mcp` packages, and stdio server +- `examples/jvm`: isolated Gradle Kotlin DSL workspace that compiles generated + Java/Kotlin JVM sidecars against Maven `protoc`, official MCP SDK artifacts, + and the local `cmd/protoc-gen-mcp` binary +- `examples/jvm/README.md`: user-facing JVM walkthrough covering the tested + compile, install, run, and stdio verification path +- `examples/jvm/settings.gradle.kts`: JVM example workspace and repository policy +- `examples/jvm/build.gradle.kts`: root JVM helper tasks, including local + `protoc-gen-mcp` compilation +- `examples/jvm/java-server`: installable Java stdio example app driven by + generated low-level tool registration +- `examples/jvm/java-server/build.gradle.kts`: Java compile gate and installed + application script using `lang=java` +- `examples/jvm/kotlin-server`: installable Kotlin stdio example app driven by + generated low-level tool registration +- `examples/jvm/kotlin-server/build.gradle.kts`: Kotlin compile gate and + installed application script using `lang=kotlin` - `easyp.yaml`: main repository config for shipped protobuf APIs - `easyp.test.yaml`: development and test config for fixture generation - `mcp/options/v1/options.proto`: custom protobuf options for MCP metadata - `internal/codegen`: code generation logic - `internal/codegen/jvm_*.go`: shared JVM semantic model, naming, and collector - foundation used by the Kotlin renderer and future Java renderer + foundation used by the Kotlin and Java renderers +- `internal/codegen/render_java.go`: self-contained Java sidecar renderer +- `internal/codegen/java_contract_test.go`: Java public API, low-level SDK seam, + schema/ProtoJSON path, metadata projection, and fail-fast contract tests - `internal/codegen/render_kotlin.go`: self-contained Kotlin sidecar renderer - `internal/codegen/kotlin_contract_test.go`: Kotlin public API, SDK wiring, schema-path, and JVM import contract tests @@ -64,8 +86,8 @@ revised. - Python generation emits package `__init__.py` files next to generated `*_mcp.py` modules so generated directories can be imported as Python packages -- `testdata/golden`: golden snapshots for generated Go, Python, and Kotlin - binding files +- `testdata/golden`: golden snapshots for generated Go, Python, Kotlin, and + Java binding files - `testdata/unsupported`: negative fixtures for fail-fast generator coverage ## MVP Rules @@ -112,6 +134,12 @@ revised. - Generated Kotlin files expose `ToolHandler` - Generated Kotlin files expose `registerTools(server: Server, impl: ToolHandler, namespace: String? = null)` +- Generated Java files expose one top-level `public final Mcp` + sidecar class per proto file +- Generated Java files expose nested `ToolHandler` interfaces inside + the sidecar class +- Generated Java files expose + `registerTools(McpServerTransportProvider transportProvider, ToolHandler impl, String namespace)` - Runtime exposes only the minimal registration options used by generated code - Generated MCP tool names must not contain dots; namespace prefixes and method names are joined with underscores, and any dots in configured segments are @@ -123,14 +151,23 @@ revised. ## Current Status - Implemented: - - `cmd/protoc-gen-mcp` plugin scaffold and generated `*.mcp.go` bindings +- `cmd/protoc-gen-mcp` plugin scaffold and generated `*.mcp.go` bindings - typed plugin option parsing for `lang=go|python|kotlin|java` and `python_runtime=google.protobuf|betterproto|grpclib` - shared JVM foundation for `lang=kotlin` and `lang=java`: parser and generator dispatch accept both targets, collect SDK-neutral `internal/codegen/jvm_*.go` models, preserve existing `FileModel` schema - JSON/annotations/icons/type semantics, and keep Java collect-only until its - renderer phase + JSON/annotations/icons/type semantics, and feed both the Kotlin and Java + renderers + - generated self-contained Java `*.java` sidecars for `lang=java`, targeting + the official Java MCP SDK through the low-level + `McpServerTransportProvider.setSessionFactory(...)` seam, including one + filename-matching `public final` sidecar class per proto file, nested + protobuf-typed handler interfaces, namespace-aware + `registerTools(...)`, generated `tools/list`/`tools/call` + request-handler wiring, raw schema JSON constants, official ProtoJSON + parse/marshal helpers, raw protocol-map metadata projection for + annotations/icons/execution, and generator-owned input/output validation - generated self-contained Kotlin `*_mcp.kt` bindings for `lang=kotlin`, targeting `io.modelcontextprotocol:kotlin-sdk-server`, including protobuf-typed handler interfaces, namespace-aware @@ -220,6 +257,26 @@ revised. - underscore-only MCP tool naming; expected example tool names are `example_CreateReport`, `example_DescribeAdvancedShapes`, `example_DescribeScalarShapes`, and `example_Health` + - representative Java output for `lang=java` is locked by + `testdata/golden/example_mcp.java.golden`, and generator coverage includes a + narrow `javac` compile smoke proving that example handler code compiles + against the generated Java API without depending on repository-internal + runtime types; full runnable Java example/runtime proof remains deferred to + Phase 04 +- `examples/jvm` provides a real Gradle compile gate that builds the local + `protoc-gen-mcp` binary, runs Maven `protoc 4.34.1`, and compiles generated + Java/Kotlin sidecars against `io.modelcontextprotocol.sdk:mcp:1.1.1`, + `io.modelcontextprotocol:kotlin-sdk-server:0.11.1`, and + `com.networknt:json-schema-validator:1.5.9` +- installable Java and Kotlin stdio example servers under `examples/jvm`, + built through Gradle `installDist`, that register generated tools through + `ExampleMcp.registerExampleAPITools(...)` and + `registerExampleAPITools(server, impl, namespace = "example")` instead of + SDK `addTool` APIs +- repository docs now route JVM users through `examples/jvm/README.md`, and + rollout messaging states that releases publish the `protoc-gen-mcp binary` + while downstream JVM users compile generated sources against the official SDK + artifacts - `example_DescribeAdvancedShapes` covers the full current advanced contract matrix in the test server: maps, timestamps, durations, field masks, `Struct`/`Value`/`ListValue`, `Any`, scalar wrappers, raw float ProtoJSON, @@ -231,14 +288,25 @@ revised. proto3 `optional` scalar/enum fields - Verified: - `easyp` lint and generation flows for `mcp` and `internal/testproto` - - `go test ./internal/codegen -count=1` for generator, Go/Python, Kotlin, + - `go test ./internal/codegen -count=1` for generator, Go/Python/Kotlin/Java, and shared JVM foundation coverage - `go test ./internal/codegen -run 'TestGenerateKotlinExampleGolden|TestKotlinContract_.*' -count=1` for Kotlin golden output and focused Kotlin renderer contracts - - `go test ./...` - - stdio smoke tests via `internal/examplemcp/stdio_test.go` - - Python stdio integration coverage for the shared server: - `go test ./internal/examplemcp -run 'TestPythonServerOverStdio|TestPythonServerRejectsInvalidOutputOverStdio' -count=1` +- `go test ./internal/codegen -run 'TestJavaContract_.*|TestGenerateJavaExampleGolden|TestGenerateJavaExampleHandlerCompileSmoke|TestGenerate_JavaTargetEmitsOutput' -count=1` + for Java golden output, low-level renderer contracts, and the Phase 03 + narrow `javac` API compile smoke +- `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` + for the Phase 04 JVM compile gate +- `gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist` + for installable JVM example scripts +- `go test ./...` +- stdio smoke tests via `internal/examplemcp/stdio_test.go` +- `go test ./internal/examplemcp -run 'TestJava.*OverStdio' -count=1` + for Java installed-script stdio parity, invalid input, and invalid output +- `go test ./internal/examplemcp -run 'TestKotlin.*OverStdio' -count=1` + for Kotlin installed-script stdio parity, invalid input, and invalid output +- Python stdio integration coverage for the shared server: + `go test ./internal/examplemcp -run 'TestPythonServerOverStdio|TestPythonServerRejectsInvalidOutputOverStdio' -count=1` - Python stdio integration coverage for standalone examples: `go test ./examples -run TestPythonExamplesOverStdio -count=1` - Python stdio integration coverage for the Python-only standalone example: @@ -292,6 +360,10 @@ revised. - Generate test fixtures: `easyp --cfg easyp.test.yaml generate -p internal/testproto -r .` - Generate standalone example artifacts: - `cd examples && make generate` +- Run JVM compile gate: + - `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` +- Install JVM example scripts: + - `gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist` - Run Python-only standalone example: - `cd examples/5_python_standalone && make setup && make run` - Build plugin: `go build ./cmd/protoc-gen-mcp` @@ -303,3 +375,7 @@ revised. - `python /abs/path/to/protoc-gen-mcp/cmd/example-python-mcp-server/main.py` - Run built example MCP server binary: `./example-mcp-server` - Run tests: `go test ./...` +- Run Java stdio example tests: + - `go test ./internal/examplemcp -run 'TestJava.*OverStdio' -count=1` +- Run Kotlin stdio example tests: + - `go test ./internal/examplemcp -run 'TestKotlin.*OverStdio' -count=1` diff --git a/README.md b/README.md index 17a3c22..66017ac 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,24 @@ # protoc-gen-mcp -`protoc-gen-mcp` generates Go and Python MCP tool bindings from protobuf services. +`protoc-gen-mcp` generates Go, Python, Kotlin, and Java MCP tool bindings +from protobuf services. ## MVP - protobuf is the source of truth -- generator emits typed Go and Python MCP bindings +- generator emits typed Go, Python, Kotlin, and Java MCP bindings - Go runtime uses the official Go MCP SDK - Python runtime targets the official MCP Python SDK with `google.protobuf` +- Kotlin runtime targets the official `io.modelcontextprotocol:kotlin-sdk-server` + SDK +- Java runtime targets the official `io.modelcontextprotocol.sdk:mcp` SDK - generated Python handlers use dataclasses and explicit `oneof` wrapper types from `*_mcp.py`; `*_pb2.py` stays an internal runtime dependency +- generated Kotlin handlers implement `ToolHandler` and are registered + through `registerTools(server: Server, impl: ToolHandler, namespace: String? = null)` +- generated Java handlers implement nested `ToolHandler` interfaces + inside a generated `Mcp` sidecar and are registered through + `registerTools(McpServerTransportProvider transportProvider, ToolHandler impl, String namespace)` - request and response JSON follows ProtoJSON rules - runtime validation is driven by generated JSON Schema @@ -23,6 +32,9 @@ easyp --cfg easyp.yaml lint -p mcp -r . easyp --cfg easyp.yaml generate -p mcp -r . easyp --cfg easyp.test.yaml lint -p internal/testproto -r . easyp --cfg easyp.test.yaml generate -p internal/testproto -r . +gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin +gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist +go test ./internal/examplemcp -run 'Test(Java|Kotlin).*OverStdio' -count=1 go test ./... goreleaser check ``` @@ -36,10 +48,34 @@ the MCP options package. CI is implemented in [tests.yml](.github/workflows/tests.yml) and runs config validation, Easyp lint, Easyp generation, a generated-file -freshness check, and `go test ./...`. Releases are implemented in +freshness check, JVM compile/install gates, JVM stdio parity checks, and +`go test ./...`. Releases are implemented in [release.yml](.github/workflows/release.yml) and use [`.goreleaser.yaml`](.goreleaser.yaml) -to publish tagged builds of `protoc-gen-mcp`. +to publish tagged builds of the `protoc-gen-mcp` binary. This repository does +not publish separate Java or Kotlin runtime artifacts. + +## JVM Support + +JVM support is implemented and CI-verified through the runnable +[`examples/jvm`](examples/jvm/README.md) workspace. + +- JVM prerequisites for the in-repo walkthrough are Go 1.24+, JDK 17+, and + Gradle 9.2+. +- The JVM generator modes are `lang=java` and `lang=kotlin`. +- The Java path compiles generated protobuf Java output plus a generated + `lang=java` MCP sidecar against the official + `io.modelcontextprotocol.sdk:mcp` SDK. +- The Kotlin path follows the tested dual-generation flow from + `examples/jvm/kotlin-server/build.gradle.kts`: Java protobuf output, Kotlin + protobuf output, and then the `lang=kotlin` MCP sidecar are all required. +- The canonical runnable JVM verification path uses `installDist` plus the + installed scripts under `examples/jvm/*/build/install/.../bin/*`, matching + `internal/examplemcp/jvm_stdio_test.go` and CI. + +Use the root README for the language matrix and repository workflow, then go to +[examples/jvm/README.md](examples/jvm/README.md) for the exact Java/Kotlin +walkthrough. ## Test MCP Server @@ -62,12 +98,18 @@ The example server currently exposes: ## Examples -We provide several standalone, runnable examples demonstrating the power of generated MCP tools, protobuf `options`, validation constraints, and integration with the official Go and Python SDKs. Check out the [examples/](examples/) directory for: +We provide several standalone, runnable examples demonstrating generated MCP +tools, protobuf `options`, validation constraints, and integration with the +official Go, Python, Kotlin, and Java SDKs. Check out the +[examples/](examples/) directory for: - [1_helloworld](examples/1_helloworld/) - Minimal Quickstart setup. - [2_weather_api](examples/2_weather_api/) - Read-only queries, validation limits, Oneofs. - [3_file_manager](examples/3_file_manager/) - Destructive tools and schema-based string parameter constraints. - [4_crm_system](examples/4_crm_system/) - A full mock system with FieldMask partial updates, custom icons mapping, schemas nested types, and advanced array filters. - [5_python_standalone](examples/5_python_standalone/) - A Python-only user-style project with its own `pyproject.toml`, `easyp.yaml`, generated bindings, and stdio server. +- [jvm](examples/jvm/README.md) - A Java/Kotlin official SDK workspace with + Gradle-managed protobuf generation, `lang=java` / `lang=kotlin` sidecars, + `installDist` scripts, and stdio verification. ## Agent Skill @@ -88,7 +130,7 @@ policy, ProtoJSON contract, and common patterns. ## Generation With Easyp The intended workflow is `easyp`, not manual `protoc` invocation. For mixed -Go/Python projects, `easyp` can drive `protoc-gen-go`, the standard Python +Go/Python/JVM projects, `easyp` can drive `protoc-gen-go`, the standard Python protobuf generator, and `protoc-gen-mcp` from one config. Before running generation, make sure `protoc-gen-go` is installed and available @@ -131,6 +173,16 @@ generate: paths: source_relative lang: python python_runtime: google.protobuf + - command: ["go", "run", "github.com/easyp-tech/protoc-gen-mcp/cmd/protoc-gen-mcp@latest"] + out: . + opts: + paths: source_relative + lang: java + - command: ["go", "run", "github.com/easyp-tech/protoc-gen-mcp/cmd/protoc-gen-mcp@latest"] + out: . + opts: + paths: source_relative + lang: kotlin ``` Typical commands: @@ -150,6 +202,14 @@ special Easyp override is required for `mcp.options.v1`, because the package declares `go_package` directly in `options.proto`. For reproducible builds, prefer pinning a specific tag instead of `@latest`. +For JVM consumers, the language selectors are `lang=java` and `lang=kotlin`. +The Java path generates a Java sidecar alongside protobuf Java output. The +Kotlin path must follow the same rule as the in-repo Gradle example: Java +protobuf output, Kotlin protobuf output, and the `lang=kotlin` MCP sidecar are +all part of the working build graph. See +[examples/jvm/README.md](examples/jvm/README.md) for the runnable, tested +workspace. + For Python-only projects, omit the Go plugins and keep only the standard `python` plugin plus `protoc-gen-mcp` with `lang=python`. User-authored Python protos do not need a `go_package` option just to satisfy the generator; the diff --git a/examples/README.md b/examples/README.md index ca839c8..bcb5461 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,12 +1,17 @@ # protoc-gen-mcp Examples -This directory contains standalone, runnable examples of how to build and expose Model Context Protocol (MCP) tools using Protocol Buffers with both the Go SDK and the official Python SDK. +This directory contains standalone, runnable examples of how to build and +expose Model Context Protocol (MCP) tools using Protocol Buffers with the Go, +Python, Kotlin, and Java SDKs. ## Prerequisites - **easyp**: Ensure you have [easyp](https://github.com/easyp-tech/easyp) installed for code generation. +- **JDK 17+ and Gradle 9.2+**: Required for the dedicated JVM workspace under + [`examples/jvm`](./jvm/README.md). ## Running the Examples -1. Generate the Go and Python code from the `.proto` definitions. Run this from the root of the repository or from the examples directory: +1. Generate the Go and Python example artifacts from the `.proto` definitions. + Run this from the root of the repository or from the examples directory: ```bash cd examples make generate @@ -37,6 +42,10 @@ This directory contains standalone, runnable examples of how to build and expose make setup make run ``` +5. Run the Java/Kotlin workspace: + Follow [`examples/jvm/README.md`](./jvm/README.md) for the tested + `compileJava` / `compileKotlin`, `installDist`, and installed-script stdio + flow. When you inspect tools in clients like `@modelcontextprotocol/inspector`, remember that omitted tool-annotation hints are often rendered with @@ -77,3 +86,15 @@ protobuf contract, generated bindings, and stdio server. - **Independent environment**: `make setup` creates a local `.venv` and installs the official Python MCP SDK plus protobuf/jsonschema dependencies. - **No import hacks**: `server.py` imports generated code with `from proto import notebook_mcp` and does not edit `sys.path`. - **Python-only proto**: the user-authored proto does not need `go_package`; `protoc-gen-mcp` synthesizes internal metadata for Python generation. + +### [jvm](./jvm/README.md) (Java & Kotlin Official SDK Workspace) +An isolated Gradle workspace that generates and runs Java and Kotlin MCP +servers against the official JVM SDKs. +- **Compile gate**: `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` +- **Installable scripts**: `gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist` +- **Verified runtime path**: installed scripts under `build/install/.../bin/...` +- **Kotlin dual generation**: Java protobuf output, Kotlin protobuf output, + and the `lang=kotlin` MCP sidecar all participate in the tested build graph + +See [`examples/jvm/README.md`](./jvm/README.md) for the full runnable +walkthrough. diff --git a/examples/jvm/README.md b/examples/jvm/README.md new file mode 100644 index 0000000..4e1a9a9 --- /dev/null +++ b/examples/jvm/README.md @@ -0,0 +1,107 @@ +# JVM MCP Workspace + +This workspace is the canonical runnable Java/Kotlin reference for JVM support +in this repository. It builds the local `protoc-gen-mcp` binary, generates +protobuf classes and JVM sidecars from `internal/testproto/example/v1/example.proto`, +and runs installable stdio servers against the official JVM MCP SDKs. + +The workspace documents the tested paths for `lang=java` and `lang=kotlin`. +For release scope: this repository publishes the `protoc-gen-mcp binary`; it +does not publish separate Java or Kotlin runtime artifacts. Downstream JVM +projects compile generated sources against the official SDK coordinates used +here. + +## Prerequisites + +- Go 1.24+ +- JDK 17+ +- Gradle 9.2+ + +You do not need a separately installed `protoc` for this workspace. The +Gradle build uses the Maven artifact `com.google.protobuf:protoc:4.34.1`. + +## Workspace Layout + +- `build.gradle.kts` builds the local `protoc-gen-mcp` binary used by both JVM subprojects. +- `java-server/build.gradle.kts` generates protobuf Java output plus the `lang=java` MCP sidecar. +- `kotlin-server/build.gradle.kts` generates Java protobuf output, Kotlin protobuf output, and the `lang=kotlin` MCP sidecar. +- `java-server/src/main/java/.../ExampleJavaServer.java` and `kotlin-server/src/main/kotlin/.../ExampleKotlinServer.kt` are the executable stdio examples. + +## Generate And Compile + +Compile both JVM targets from the repository root: + +```bash +gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin +``` + +The Java subproject passes `lang=java` to `protoc-gen-mcp` and compiles the +generated Java sidecar alongside protobuf Java output. + +The Kotlin subproject passes `lang=kotlin` to `protoc-gen-mcp`. Its working +path is intentionally a dual protobuf generation flow: + +- Java protobuf output +- Kotlin protobuf output +- `lang=kotlin` MCP sidecar + +All three are required for the tested Kotlin build graph. + +## Install Runnable Scripts + +Build installable stdio scripts for both targets: + +```bash +gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist +``` + +This produces the tested script entrypoints: + +- `examples/jvm/java-server/build/install/java-server/bin/java-server` +- `examples/jvm/kotlin-server/build/install/kotlin-server/bin/kotlin-server` + +Use these installed scripts as the canonical stdio launch path for manual +checks. They match the repository's automated runtime proof. + +## Run + +From the repository root, start the installed Java server: + +```bash +examples/jvm/java-server/build/install/java-server/bin/java-server +``` + +Start the installed Kotlin server: + +```bash +examples/jvm/kotlin-server/build/install/kotlin-server/bin/kotlin-server +``` + +The example servers register generated tools through the current public JVM +APIs: + +- Java uses `ExampleMcp.registerExampleAPITools(transportProvider, new Handler(), "example")` +- Kotlin uses `registerExampleAPITools(server, Handler(), namespace = "example")` + +## Verify + +Run the same stdio parity proof used by the repository: + +```bash +go test ./internal/examplemcp -run 'Test(Java|Kotlin).*OverStdio' -count=1 +``` + +The tests validate: + +- `tools/list` parity for the generated JVM bindings +- representative `tools/call` behavior +- invalid input rejection +- invalid output validation failure + +For the full repository-level JVM truth path, run: + +```bash +gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin +gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist +go test ./internal/examplemcp -run 'Test(Java|Kotlin).*OverStdio' -count=1 +``` diff --git a/examples/jvm/java-server/build.gradle.kts b/examples/jvm/java-server/build.gradle.kts index 2b97b01..b0ee0b4 100644 --- a/examples/jvm/java-server/build.gradle.kts +++ b/examples/jvm/java-server/build.gradle.kts @@ -2,6 +2,7 @@ import com.google.protobuf.gradle.* import org.gradle.api.tasks.Sync plugins { + application java id("com.google.protobuf") } @@ -17,8 +18,11 @@ java { targetCompatibility = JavaVersion.VERSION_17 } +application { + mainClass.set("internal.examplemcp.java.ExampleJavaServer") +} + val repoRoot = rootProject.projectDir.resolve("../..").canonicalFile -layout.buildDirectory.set(repoRoot.parentFile.resolve(".protoc-gen-mcp-jvm-build").resolve(project.name)) val protocGenMcpBinary = rootProject.layout.buildDirectory.file("tools/protoc-gen-mcp") val stagedProtoDir = layout.buildDirectory.dir("staged-proto") val stageProtoSources = tasks.register("stageProtoSources") { @@ -67,3 +71,7 @@ protobuf { tasks.named("compileJava") { dependsOn(tasks.named("generateProto")) } + +tasks.named("processResources") { + dependsOn(stageProtoSources) +} diff --git a/examples/jvm/java-server/src/main/java/internal/examplemcp/java/ExampleJavaServer.java b/examples/jvm/java-server/src/main/java/internal/examplemcp/java/ExampleJavaServer.java new file mode 100644 index 0000000..dcd6328 --- /dev/null +++ b/examples/jvm/java-server/src/main/java/internal/examplemcp/java/ExampleJavaServer.java @@ -0,0 +1,234 @@ +package internal.examplemcp.java; + +import com.google.protobuf.Empty; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import internal.testproto.example.v1.Example; +import internal.testproto.example.v1.ExampleMcp; + +public final class ExampleJavaServer { + private static final String INVALID_OUTPUT_ENV = "PROTOC_GEN_MCP_JVM_INVALID_OUTPUT"; + + private ExampleJavaServer() { + } + + public static void main(String[] args) { + validateInvalidOutputMode(); + + StdioServerTransportProvider transportProvider = + new StdioServerTransportProvider(McpJsonDefaults.getMapper()); + ExampleMcp.registerExampleAPITools(transportProvider, new Handler(), "example"); + } + + private static void validateInvalidOutputMode() { + String mode = System.getenv(INVALID_OUTPUT_ENV); + if (mode == null || mode.isEmpty()) { + return; + } + if (!"create_report".equals(mode)) { + throw new IllegalArgumentException("unsupported " + INVALID_OUTPUT_ENV + " value: " + mode); + } + } + + private static boolean invalidCreateReportOutputRequested() { + return "create_report".equals(System.getenv(INVALID_OUTPUT_ENV)); + } + + private static final class Handler implements ExampleMcp.ExampleAPIToolHandler { + @Override + public Example.CreateReportResponse createReport( + McpAsyncServerExchange ctx, + Example.CreateReportRequest request + ) { + Example.CreateReportResponse.Builder response = Example.CreateReportResponse.newBuilder() + .setReportId("report-1") + .setTotalCount(42) + .setStatus(Example.ReportStatus.REPORT_STATUS_OK); + if (invalidCreateReportOutputRequested()) { + return response.build(); + } + return response + .setDetails(request.getDetails()) + .addWarnings("none") + .build(); + } + + @Override + public Example.PingResponse ping( + McpAsyncServerExchange ctx, + Example.PingRequest request + ) { + return Example.PingResponse.newBuilder() + .setAck(Empty.getDefaultInstance()) + .build(); + } + + @Override + public Example.DescribeAdvancedShapesResponse describeAdvancedShapes( + McpAsyncServerExchange ctx, + Example.DescribeAdvancedShapesRequest request + ) { + Example.DescribeAdvancedShapesResponse.Builder response = + Example.DescribeAdvancedShapesResponse.newBuilder() + .putAllLabels(request.getLabelsMap()) + .putAllQuantities(request.getQuantitiesMap()) + .putAllToggles(request.getTogglesMap()) + .putAllLimits(request.getLimitsMap()); + + if (request.hasObservedAt()) { + response.setObservedAt(request.getObservedAt()); + } + if (request.hasTtl()) { + response.setTtl(request.getTtl()); + } + if (request.hasPayload()) { + response.setPayload(request.getPayload()); + } + if (request.hasItems()) { + response.setItems(request.getItems()); + } + if (request.hasDynamic()) { + response.setDynamic(request.getDynamic()); + } + if (request.hasNote()) { + response.setNote(request.getNote()); + } + if (request.hasTotal()) { + response.setTotal(request.getTotal()); + } + if (request.hasEnabled()) { + response.setEnabled(request.getEnabled()); + } + if (request.hasRatio()) { + response.setRatio(request.getRatio()); + } + if (request.hasMask()) { + response.setMask(request.getMask()); + } + if (request.hasBlob()) { + response.setBlob(request.getBlob()); + } + if (request.hasSmallTotal()) { + response.setSmallTotal(request.getSmallTotal()); + } + if (request.hasUintTotal()) { + response.setUintTotal(request.getUintTotal()); + } + if (request.hasHugeTotal()) { + response.setHugeTotal(request.getHugeTotal()); + } + if (request.hasWeight()) { + response.setWeight(request.getWeight()); + } + if (request.hasRawRatio()) { + response.setRawRatio(request.getRawRatio()); + } + if (request.hasTree()) { + response.setTree(request.getTree()); + } + if (request.hasDetailAny()) { + response.setDetailAny(request.getDetailAny()); + } + if (request.hasDurationAny()) { + response.setDurationAny(request.getDurationAny()); + } + + switch (request.getSelectorCase()) { + case CITY_ALIAS -> response.setCityAlias(request.getCityAlias()); + case CITY_ID -> response.setCityId(request.getCityId()); + case CITY_DETAILS -> response.setCityDetails(request.getCityDetails()); + case SELECTOR_NOT_SET -> { + } + } + + return response.build(); + } + + @Override + public Example.DescribeScalarShapesResponse describeScalarShapes( + McpAsyncServerExchange ctx, + Example.DescribeScalarShapesRequest request + ) { + Example.DescribeScalarShapesResponse.Builder response = + Example.DescribeScalarShapesResponse.newBuilder() + .setBoolFlag(request.getBoolFlag()) + .setTextValue(request.getTextValue()) + .setBytesValue(request.getBytesValue()) + .setInt32Value(request.getInt32Value()) + .setSint32Value(request.getSint32Value()) + .setSfixed32Value(request.getSfixed32Value()) + .setUint32Value(request.getUint32Value()) + .setFixed32Value(request.getFixed32Value()) + .setInt64Value(request.getInt64Value()) + .setSint64Value(request.getSint64Value()) + .setSfixed64Value(request.getSfixed64Value()) + .setUint64Value(request.getUint64Value()) + .setFixed64Value(request.getFixed64Value()) + .setFloatValue(request.getFloatValue()) + .setDoubleValue(request.getDoubleValue()) + .setStatus(request.getStatus()) + .setDetails(request.getDetails()) + .addAllSamples(request.getSamplesList()); + + if (request.hasOptionalBoolFlag()) { + response.setOptionalBoolFlag(request.getOptionalBoolFlag()); + } + if (request.hasOptionalTextValue()) { + response.setOptionalTextValue(request.getOptionalTextValue()); + } + if (request.hasOptionalBytesValue()) { + response.setOptionalBytesValue(request.getOptionalBytesValue()); + } + if (request.hasOptionalInt32Value()) { + response.setOptionalInt32Value(request.getOptionalInt32Value()); + } + if (request.hasOptionalSint32Value()) { + response.setOptionalSint32Value(request.getOptionalSint32Value()); + } + if (request.hasOptionalSfixed32Value()) { + response.setOptionalSfixed32Value(request.getOptionalSfixed32Value()); + } + if (request.hasOptionalUint32Value()) { + response.setOptionalUint32Value(request.getOptionalUint32Value()); + } + if (request.hasOptionalFixed32Value()) { + response.setOptionalFixed32Value(request.getOptionalFixed32Value()); + } + if (request.hasOptionalInt64Value()) { + response.setOptionalInt64Value(request.getOptionalInt64Value()); + } + if (request.hasOptionalSint64Value()) { + response.setOptionalSint64Value(request.getOptionalSint64Value()); + } + if (request.hasOptionalSfixed64Value()) { + response.setOptionalSfixed64Value(request.getOptionalSfixed64Value()); + } + if (request.hasOptionalUint64Value()) { + response.setOptionalUint64Value(request.getOptionalUint64Value()); + } + if (request.hasOptionalFixed64Value()) { + response.setOptionalFixed64Value(request.getOptionalFixed64Value()); + } + if (request.hasOptionalFloatValue()) { + response.setOptionalFloatValue(request.getOptionalFloatValue()); + } + if (request.hasOptionalDoubleValue()) { + response.setOptionalDoubleValue(request.getOptionalDoubleValue()); + } + if (request.hasOptionalStatus()) { + response.setOptionalStatus(request.getOptionalStatus()); + } + + return response.build(); + } + + @Override + public Example.HiddenThingResponse hiddenThing( + McpAsyncServerExchange ctx, + Example.HiddenThingRequest request + ) { + return Example.HiddenThingResponse.newBuilder().build(); + } + } +} diff --git a/examples/jvm/kotlin-server/build.gradle.kts b/examples/jvm/kotlin-server/build.gradle.kts index ae108ee..7c6db7e 100644 --- a/examples/jvm/kotlin-server/build.gradle.kts +++ b/examples/jvm/kotlin-server/build.gradle.kts @@ -2,6 +2,7 @@ import com.google.protobuf.gradle.* import org.gradle.api.tasks.Sync plugins { + application id("org.jetbrains.kotlin.jvm") java id("com.google.protobuf") @@ -13,6 +14,7 @@ dependencies { implementation("com.google.protobuf:protobuf-kotlin:4.34.1") implementation("com.google.protobuf:protobuf-java-util:4.34.1") implementation("com.networknt:json-schema-validator:1.5.9") + runtimeOnly("org.slf4j:slf4j-nop:2.0.17") } java { @@ -20,8 +22,11 @@ java { targetCompatibility = JavaVersion.VERSION_17 } +application { + mainClass.set("internal.examplemcp.kotlin.ExampleKotlinServerKt") +} + val repoRoot = rootProject.projectDir.resolve("../..").canonicalFile -layout.buildDirectory.set(repoRoot.parentFile.resolve(".protoc-gen-mcp-jvm-build").resolve(project.name)) val protocGenMcpBinary = rootProject.layout.buildDirectory.file("tools/protoc-gen-mcp") val stagedProtoDir = layout.buildDirectory.dir("staged-proto") val stageProtoSources = tasks.register("stageProtoSources") { @@ -80,3 +85,7 @@ protobuf { tasks.named("compileKotlin") { dependsOn(tasks.named("generateProto")) } + +tasks.named("processResources") { + dependsOn(stageProtoSources) +} diff --git a/examples/jvm/kotlin-server/src/main/kotlin/internal/examplemcp/kotlin/ExampleKotlinServer.kt b/examples/jvm/kotlin-server/src/main/kotlin/internal/examplemcp/kotlin/ExampleKotlinServer.kt new file mode 100644 index 0000000..f873b64 --- /dev/null +++ b/examples/jvm/kotlin-server/src/main/kotlin/internal/examplemcp/kotlin/ExampleKotlinServer.kt @@ -0,0 +1,250 @@ +package internal.examplemcp.kotlin + +import com.google.protobuf.Empty +import internal.testproto.example.v1.Example +import internal.testproto.example.v1.ExampleAPIToolHandler +import internal.testproto.example.v1.registerExampleAPITools +import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered + +private const val INVALID_OUTPUT_ENV = "PROTOC_GEN_MCP_JVM_INVALID_OUTPUT" + +fun main() = runBlocking { + validateInvalidOutputMode() + disableKotlinLoggingStartupMessage() + + val closed = CompletableDeferred() + val server = Server( + Implementation(name = "protoc-gen-mcp-kotlin-example-server", version = "v0.0.1"), + ServerOptions(ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false))), + ) + server.onClose { closed.complete(Unit) } + + registerExampleAPITools(server, Handler(), namespace = "example") + + val transport = StdioServerTransport( + System.`in`.asSource().buffered(), + System.out.asSink().buffered(), + ) + server.createSession(transport) + closed.await() +} + +private fun validateInvalidOutputMode() { + val mode = System.getenv(INVALID_OUTPUT_ENV) + if (mode.isNullOrEmpty()) { + return + } + require(mode == "create_report") { "unsupported $INVALID_OUTPUT_ENV value: $mode" } +} + +private fun invalidCreateReportOutputRequested(): Boolean = + System.getenv(INVALID_OUTPUT_ENV) == "create_report" + +private fun disableKotlinLoggingStartupMessage() { + runCatching { + val configClass = Class.forName("io.github.oshai.kotlinlogging.KotlinLoggingConfiguration") + val instance = configClass.getField("INSTANCE").get(null) + val setter = configClass.getMethod("setLogStartupMessage", Boolean::class.javaPrimitiveType) + setter.invoke(instance, false) + } +} + +private class Handler : ExampleAPIToolHandler { + override suspend fun createReport( + ctx: ClientConnection, + request: Example.CreateReportRequest, + ): Example.CreateReportResponse { + val response = Example.CreateReportResponse.newBuilder() + .setReportId("report-1") + .setTotalCount(42) + .setStatus(Example.ReportStatus.REPORT_STATUS_OK) + if (invalidCreateReportOutputRequested()) { + return response.build() + } + return response + .setDetails(request.details) + .addWarnings("none") + .build() + } + + override suspend fun ping( + ctx: ClientConnection, + request: Example.PingRequest, + ): Example.PingResponse { + return Example.PingResponse.newBuilder() + .setAck(Empty.getDefaultInstance()) + .build() + } + + override suspend fun describeAdvancedShapes( + ctx: ClientConnection, + request: Example.DescribeAdvancedShapesRequest, + ): Example.DescribeAdvancedShapesResponse { + val response = Example.DescribeAdvancedShapesResponse.newBuilder() + .putAllLabels(request.labelsMap) + .putAllQuantities(request.quantitiesMap) + .putAllToggles(request.togglesMap) + .putAllLimits(request.limitsMap) + + if (request.hasObservedAt()) { + response.setObservedAt(request.observedAt) + } + if (request.hasTtl()) { + response.setTtl(request.ttl) + } + if (request.hasPayload()) { + response.setPayload(request.payload) + } + if (request.hasItems()) { + response.setItems(request.items) + } + if (request.hasDynamic()) { + response.setDynamic(request.dynamic) + } + if (request.hasNote()) { + response.setNote(request.note) + } + if (request.hasTotal()) { + response.setTotal(request.total) + } + if (request.hasEnabled()) { + response.setEnabled(request.enabled) + } + if (request.hasRatio()) { + response.setRatio(request.ratio) + } + if (request.hasMask()) { + response.setMask(request.mask) + } + if (request.hasBlob()) { + response.setBlob(request.blob) + } + if (request.hasSmallTotal()) { + response.setSmallTotal(request.smallTotal) + } + if (request.hasUintTotal()) { + response.setUintTotal(request.uintTotal) + } + if (request.hasHugeTotal()) { + response.setHugeTotal(request.hugeTotal) + } + if (request.hasWeight()) { + response.setWeight(request.weight) + } + if (request.hasRawRatio()) { + response.setRawRatio(request.rawRatio) + } + if (request.hasTree()) { + response.setTree(request.tree) + } + if (request.hasDetailAny()) { + response.setDetailAny(request.detailAny) + } + if (request.hasDurationAny()) { + response.setDurationAny(request.durationAny) + } + + when (request.selectorCase) { + Example.DescribeAdvancedShapesRequest.SelectorCase.CITY_ALIAS -> response.setCityAlias(request.cityAlias) + Example.DescribeAdvancedShapesRequest.SelectorCase.CITY_ID -> response.setCityId(request.cityId) + Example.DescribeAdvancedShapesRequest.SelectorCase.CITY_DETAILS -> response.setCityDetails(request.cityDetails) + Example.DescribeAdvancedShapesRequest.SelectorCase.SELECTOR_NOT_SET -> Unit + null -> Unit + } + + return response.build() + } + + override suspend fun describeScalarShapes( + ctx: ClientConnection, + request: Example.DescribeScalarShapesRequest, + ): Example.DescribeScalarShapesResponse { + val response = Example.DescribeScalarShapesResponse.newBuilder() + .setBoolFlag(request.boolFlag) + .setTextValue(request.textValue) + .setBytesValue(request.bytesValue) + .setInt32Value(request.int32Value) + .setSint32Value(request.sint32Value) + .setSfixed32Value(request.sfixed32Value) + .setUint32Value(request.uint32Value) + .setFixed32Value(request.fixed32Value) + .setInt64Value(request.int64Value) + .setSint64Value(request.sint64Value) + .setSfixed64Value(request.sfixed64Value) + .setUint64Value(request.uint64Value) + .setFixed64Value(request.fixed64Value) + .setFloatValue(request.floatValue) + .setDoubleValue(request.doubleValue) + .setStatus(request.status) + .setDetails(request.details) + .addAllSamples(request.samplesList) + + if (request.hasOptionalBoolFlag()) { + response.setOptionalBoolFlag(request.optionalBoolFlag) + } + if (request.hasOptionalTextValue()) { + response.setOptionalTextValue(request.optionalTextValue) + } + if (request.hasOptionalBytesValue()) { + response.setOptionalBytesValue(request.optionalBytesValue) + } + if (request.hasOptionalInt32Value()) { + response.setOptionalInt32Value(request.optionalInt32Value) + } + if (request.hasOptionalSint32Value()) { + response.setOptionalSint32Value(request.optionalSint32Value) + } + if (request.hasOptionalSfixed32Value()) { + response.setOptionalSfixed32Value(request.optionalSfixed32Value) + } + if (request.hasOptionalUint32Value()) { + response.setOptionalUint32Value(request.optionalUint32Value) + } + if (request.hasOptionalFixed32Value()) { + response.setOptionalFixed32Value(request.optionalFixed32Value) + } + if (request.hasOptionalInt64Value()) { + response.setOptionalInt64Value(request.optionalInt64Value) + } + if (request.hasOptionalSint64Value()) { + response.setOptionalSint64Value(request.optionalSint64Value) + } + if (request.hasOptionalSfixed64Value()) { + response.setOptionalSfixed64Value(request.optionalSfixed64Value) + } + if (request.hasOptionalUint64Value()) { + response.setOptionalUint64Value(request.optionalUint64Value) + } + if (request.hasOptionalFixed64Value()) { + response.setOptionalFixed64Value(request.optionalFixed64Value) + } + if (request.hasOptionalFloatValue()) { + response.setOptionalFloatValue(request.optionalFloatValue) + } + if (request.hasOptionalDoubleValue()) { + response.setOptionalDoubleValue(request.optionalDoubleValue) + } + if (request.hasOptionalStatus()) { + response.setOptionalStatus(request.optionalStatus) + } + + return response.build() + } + + override suspend fun hiddenThing( + ctx: ClientConnection, + request: Example.HiddenThingRequest, + ): Example.HiddenThingResponse { + return Example.HiddenThingResponse.newBuilder().build() + } +} diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 801d614..40113a1 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -57,15 +57,16 @@ func Generate(plugin *protogen.Plugin, opts Options) error { } return nil case LanguageJava: - for _, file := range plugin.Files { - if !file.Generate { + models, orderedFiles, err := collectJavaModels(plugin, opts) + if err != nil { + return err + } + for _, file := range orderedFiles { + model := models[file.Desc.Path()] + if len(model.Services) == 0 { continue } - model, err := CollectFileModel(file, opts) - if err != nil { - return err - } - if _, err := CollectJVMFileModel(file, model); err != nil { + if err := renderJavaFile(plugin, model); err != nil { return err } } @@ -131,6 +132,33 @@ func collectKotlinModels(plugin *protogen.Plugin, opts Options) (map[string]JVMF return models, orderedFiles, nil } +func collectJavaModels(plugin *protogen.Plugin, opts Options) (map[string]JVMFileModel, []*protogen.File, error) { + models := make(map[string]JVMFileModel) + orderedFiles := make([]*protogen.File, 0, len(plugin.Files)) + + for _, file := range plugin.Files { + if !file.Generate { + continue + } + + model, err := CollectFileModel(file, opts) + if err != nil { + return nil, nil, err + } + jvmModel, err := CollectJVMFileModel(file, model) + if err != nil { + return nil, nil, err + } + if err := validateJavaMetadata(jvmModel); err != nil { + return nil, nil, err + } + models[file.Desc.Path()] = jvmModel + orderedFiles = append(orderedFiles, file) + } + + return models, orderedFiles, nil +} + func collectPythonModels(plugin *protogen.Plugin, opts Options) (map[string]FileModel, []*protogen.File, error) { models := make(map[string]FileModel) orderedFiles := make([]*protogen.File, 0, len(plugin.Files)) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index ea26224..cc6363f 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -54,13 +54,16 @@ func TestGenerateKotlinExampleGolden(t *testing.T) { } } -func TestGenerate_JavaTargetCollectsWithoutOutput(t *testing.T) { +func TestGenerateJavaExampleGolden(t *testing.T) { plugin := newExampleProtogenPlugin(t) if err := Generate(plugin, Options{Language: LanguageJava}); err != nil { t.Fatalf("Generate: %v", err) } - if len(plugin.Response().GetFile()) != 0 { - t.Fatalf("Java foundation dispatch emitted %d files, want 0", len(plugin.Response().GetFile())) + + got := generatedFileContent(t, plugin, exampleJavaOutputPath()) + want := readExampleJavaGolden(t, repoRoot(t)) + if !bytes.Equal(got, want) { + t.Fatalf("fresh Java renderer output differs from golden:\n%s", diffBytes(want, got)) } } @@ -90,6 +93,35 @@ func TestGenerate_KotlinTargetEmitsOutput(t *testing.T) { } } +func TestGenerate_JavaTargetEmitsOutput(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: LanguageJava}); err != nil { + t.Fatalf("Generate: %v", err) + } + + generated := string(generatedFileContent(t, plugin, exampleJavaOutputPath())) + wantSnippets := []string{ + "public final class ExampleMcp {", + "public interface ExampleAPIToolHandler {", + "public static void registerExampleAPITools(", + "transportProvider.setSessionFactory(", + "requestHandlers.put(McpSchema.METHOD_TOOLS_LIST", + "requestHandlers.put(McpSchema.METHOD_TOOLS_CALL", + "JsonFormat.parser().usingTypeRegistry(PROTO_TYPE_REGISTRY).merge(", + "alwaysPrintFieldsWithNoPresence()", + } + for _, snippet := range wantSnippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated Java missing snippet %q\n%s", snippet, generated) + } + } + for _, snippet := range []string{"addTool(", "Tool.builder("} { + if strings.Contains(generated, snippet) { + t.Fatalf("generated Java must not use convenience registration snippet %q\n%s", snippet, generated) + } + } +} + func TestGenerate_PythonEmitsMCPNamespaceBridge(t *testing.T) { plugin := newExampleProtogenPlugin(t) if err := Generate(plugin, Options{ @@ -616,6 +648,52 @@ func TestGenerate_JVMStreamingRPCFailsBeforeOutput(t *testing.T) { } } +func TestGenerateJavaExampleHandlerCompileSmoke(t *testing.T) { + if _, err := exec.LookPath("javac"); err != nil { + t.Skipf("javac not found in PATH: %v", err) + } + + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: LanguageJava}); err != nil { + t.Fatalf("Generate: %v", err) + } + + exampleFile := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] + if exampleFile == nil { + t.Fatal("example proto file not found in plugin") + } + shared, err := CollectFileModel(exampleFile, Options{Language: LanguageJava}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + jvm, err := CollectJVMFileModel(exampleFile, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel: %v", err) + } + info, err := newJavaRenderInfo(plugin, jvm) + if err != nil { + t.Fatalf("newJavaRenderInfo: %v", err) + } + filePackage, err := resolveJVMFilePackage(exampleFile) + if err != nil { + t.Fatalf("resolveJVMFilePackage: %v", err) + } + + tempDir := t.TempDir() + writeJavaTestSource(t, tempDir, exampleJavaOutputPath(), generatedFileContent(t, plugin, exampleJavaOutputPath())) + writeJavaTestSource(t, tempDir, filepath.Join("internal/testproto/example/v1", "ExampleHandler.java"), []byte(generateExampleJavaHandlerSource(t, jvm, info, filePackage.Package))) + writeJavaProtoStubs(t, tempDir, exampleFile, jvm) + writeJavaSDKStubs(t, tempDir) + + sources := collectJavaSources(t, tempDir) + cmd := exec.Command("javac", sources...) + cmd.Dir = tempDir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("javac failed: %v\n%s", err, output) + } +} + func TestGenerate_RejectsUnknownCustomParamAndAllowsBuiltInParams(t *testing.T) { root := repoRoot(t) @@ -664,6 +742,10 @@ func runEasyp(t *testing.T, dir string, config string, args ...string) string { return string(output) } +func exampleJavaOutputPath() string { + return javaSidecarOutputPath(jvmGeneratedFilenamePrefixForProtoPath("internal/testproto/example/v1/example.proto")) +} + func runEasypGenerateExpectFailure(t *testing.T, dir string, config string, pkg string) (string, error) { t.Helper() @@ -715,6 +797,17 @@ func readExampleKotlinGolden(t *testing.T, root string) []byte { return want } +func readExampleJavaGolden(t *testing.T, root string) []byte { + t.Helper() + + wantPath := filepath.Join(root, "testdata/golden/example_mcp.java.golden") + want, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("read golden file %q: %v", wantPath, err) + } + return want +} + func generatedFileContent(t *testing.T, plugin *protogen.Plugin, path string) []byte { t.Helper() @@ -728,6 +821,574 @@ func generatedFileContent(t *testing.T, plugin *protogen.Plugin, path string) [] return nil } +func writeJavaTestSource(t *testing.T, root string, relativePath string, content []byte) { + t.Helper() + + target := filepath.Join(root, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir %q: %v", filepath.Dir(target), err) + } + if err := os.WriteFile(target, content, 0o600); err != nil { + t.Fatalf("write %q: %v", target, err) + } +} + +func collectJavaSources(t *testing.T, root string) []string { + t.Helper() + + var sources []string + if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if filepath.Ext(path) == ".java" { + sources = append(sources, path) + } + return nil + }); err != nil { + t.Fatalf("walk java sources: %v", err) + } + return sources +} + +func generateExampleJavaHandlerSource(t *testing.T, model JVMFileModel, info javaRenderInfo, currentPackage string) string { + t.Helper() + + if len(model.Services) != 1 { + t.Fatalf("example JVM service count = %d, want 1", len(model.Services)) + } + + service := model.Services[0] + className := javaSidecarClassName(model.GeneratedFilenamePrefix) + var body strings.Builder + body.WriteString("package " + currentPackage + ";\n\n") + body.WriteString("import io.modelcontextprotocol.server.McpAsyncServerExchange;\n\n") + body.WriteString("public final class ExampleHandler implements " + className + "." + service.HandlerName + " {\n") + for _, method := range service.Methods { + inputType, err := info.javaTypeExpr(method.Input, map[string]bool{}, currentPackage) + if err != nil { + t.Fatalf("java input type expr for %s: %v", method.MethodName, err) + } + outputType, err := info.javaTypeExpr(method.Output, map[string]bool{}, currentPackage) + if err != nil { + t.Fatalf("java output type expr for %s: %v", method.MethodName, err) + } + body.WriteString(" @Override\n") + body.WriteString(" public " + outputType + " " + method.MethodName + "(McpAsyncServerExchange ctx, " + inputType + " request) throws Exception {\n") + body.WriteString(" return " + outputType + ".newBuilder().build();\n") + body.WriteString(" }\n") + } + body.WriteString("}\n") + return body.String() +} + +func writeJavaProtoStubs(t *testing.T, root string, file *protogen.File, model JVMFileModel) { + t.Helper() + + filePackage, err := resolveJVMFilePackage(file) + if err != nil { + t.Fatalf("resolveJVMFilePackage: %v", err) + } + + typeNames := map[string]struct{}{} + for _, service := range model.Services { + for _, method := range service.Methods { + if method.Input.Owner.IsCurrentFile { + typeNames[method.Input.PublicName] = struct{}{} + } + if method.Output.Owner.IsCurrentFile { + typeNames[method.Output.PublicName] = struct{}{} + } + } + } + + names := make([]string, 0, len(typeNames)) + for name := range typeNames { + names = append(names, name) + } + + packagePath := filepath.Join(strings.Split(filePackage.Package, ".")...) + if filePackage.MultipleFiles { + for _, name := range names { + writeJavaTestSource(t, root, filepath.Join(packagePath, name+".java"), []byte(javaTopLevelMessageStub(filePackage.Package, name))) + } + return + } + + writeJavaTestSource(t, root, filepath.Join(packagePath, filePackage.OuterClassName+".java"), []byte(javaOuterClassStub(filePackage.Package, filePackage.OuterClassName, names))) +} + +func javaTopLevelMessageStub(pkg string, name string) string { + return strings.Join([]string{ + "package " + pkg + ";", + "", + "import com.google.protobuf.Message;", + "", + "public final class " + name + " implements Message {", + " public static Builder newBuilder() {", + " return new Builder();", + " }", + "", + " public static final class Builder implements Message.Builder {", + " @Override", + " public " + name + " build() {", + " return new " + name + "();", + " }", + " }", + "}", + "", + }, "\n") +} + +func javaOuterClassStub(pkg string, outerClassName string, names []string) string { + var body strings.Builder + body.WriteString("package " + pkg + ";\n\n") + body.WriteString("import com.google.protobuf.Descriptors;\n") + body.WriteString("import com.google.protobuf.Message;\n\n") + body.WriteString("public final class " + outerClassName + " {\n") + body.WriteString(" private " + outerClassName + "() {\n") + body.WriteString(" }\n\n") + body.WriteString(" public static Descriptors.FileDescriptor getDescriptor() {\n") + body.WriteString(" return new Descriptors.FileDescriptor();\n") + body.WriteString(" }\n\n") + for _, name := range names { + body.WriteString(" public static final class " + name + " implements Message {\n") + body.WriteString(" public static Builder newBuilder() {\n") + body.WriteString(" return new Builder();\n") + body.WriteString(" }\n\n") + body.WriteString(" public static final class Builder implements Message.Builder {\n") + body.WriteString(" @Override\n") + body.WriteString(" public " + name + " build() {\n") + body.WriteString(" return new " + name + "();\n") + body.WriteString(" }\n") + body.WriteString(" }\n") + body.WriteString(" }\n\n") + } + body.WriteString("}\n") + return body.String() +} + +func writeJavaSDKStubs(t *testing.T, root string) { + t.Helper() + + stubs := map[string]string{ + "com/google/protobuf/Message.java": strings.Join([]string{ + "package com.google.protobuf;", + "", + "public interface Message {", + " interface Builder {", + " Message build();", + " }", + "}", + "", + }, "\n"), + "com/google/protobuf/Descriptors.java": strings.Join([]string{ + "package com.google.protobuf;", + "", + "import java.util.List;", + "", + "public final class Descriptors {", + " private Descriptors() {", + " }", + "", + " public static final class FileDescriptor {", + " public String getFullName() {", + " return \"stub\";", + " }", + "", + " public List getMessageTypes() {", + " return List.of();", + " }", + " }", + "", + " public static final class Descriptor {", + " public List getNestedTypes() {", + " return List.of();", + " }", + " }", + "}", + "", + }, "\n"), + "com/google/protobuf/util/JsonFormat.java": strings.Join([]string{ + "package com.google.protobuf.util;", + "", + "import com.google.protobuf.Descriptors;", + "import com.google.protobuf.Message;", + "", + "public final class JsonFormat {", + " private JsonFormat() {", + " }", + "", + " public static Parser parser() {", + " return new Parser();", + " }", + "", + " public static Printer printer() {", + " return new Printer();", + " }", + "", + " public static final class TypeRegistry {", + " public static Builder newBuilder() {", + " return new Builder();", + " }", + "", + " public static final class Builder {", + " public Builder add(Descriptors.Descriptor descriptor) {", + " return this;", + " }", + "", + " public TypeRegistry build() {", + " return new TypeRegistry();", + " }", + " }", + " }", + "", + " public static final class Parser {", + " public Parser usingTypeRegistry(TypeRegistry registry) {", + " return this;", + " }", + "", + " public void merge(String payload, Message.Builder builder) throws Exception {", + " }", + " }", + "", + " public static final class Printer {", + " public Printer usingTypeRegistry(TypeRegistry registry) {", + " return this;", + " }", + "", + " public Printer alwaysPrintFieldsWithNoPresence() {", + " return this;", + " }", + "", + " public Printer omittingInsignificantWhitespace() {", + " return this;", + " }", + "", + " public String print(Message message) throws Exception {", + " return \"{}\";", + " }", + " }", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/json/TypeRef.java": strings.Join([]string{ + "package io.modelcontextprotocol.json;", + "", + "public abstract class TypeRef {", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/json/McpJsonMapper.java": strings.Join([]string{ + "package io.modelcontextprotocol.json;", + "", + "import java.io.IOException;", + "", + "public interface McpJsonMapper {", + " T readValue(String content, TypeRef type) throws IOException;", + " T convertValue(Object fromValue, Class type);", + " String writeValueAsString(Object value) throws IOException;", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/json/McpJsonDefaults.java": strings.Join([]string{ + "package io.modelcontextprotocol.json;", + "", + "import io.modelcontextprotocol.json.schema.JsonSchemaValidator;", + "", + "public final class McpJsonDefaults {", + " private static final McpJsonMapper MAPPER = new McpJsonMapper() {", + " @Override", + " public T readValue(String content, TypeRef type) {", + " return null;", + " }", + "", + " @Override", + " public T convertValue(Object fromValue, Class type) {", + " return null;", + " }", + "", + " @Override", + " public String writeValueAsString(Object value) {", + " return \"{}\";", + " }", + " };", + "", + " private static final JsonSchemaValidator VALIDATOR = (schema, structuredContent) -> JsonSchemaValidator.ValidationResponse.asValid(\"{}\");", + "", + " private McpJsonDefaults() {", + " }", + "", + " public static McpJsonMapper getMapper() {", + " return MAPPER;", + " }", + "", + " public static JsonSchemaValidator getSchemaValidator() {", + " return VALIDATOR;", + " }", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/json/schema/JsonSchemaValidator.java": strings.Join([]string{ + "package io.modelcontextprotocol.json.schema;", + "", + "import java.util.Map;", + "", + "public interface JsonSchemaValidator {", + " ValidationResponse validate(Map schema, Object structuredContent);", + "", + " final class ValidationResponse {", + " private final boolean valid;", + " private final String errorMessage;", + "", + " private ValidationResponse(boolean valid, String errorMessage) {", + " this.valid = valid;", + " this.errorMessage = errorMessage;", + " }", + "", + " public static ValidationResponse asValid(String ignored) {", + " return new ValidationResponse(true, null);", + " }", + "", + " public static ValidationResponse asInvalid(String errorMessage) {", + " return new ValidationResponse(false, errorMessage);", + " }", + "", + " public boolean valid() {", + " return this.valid;", + " }", + "", + " public String errorMessage() {", + " return this.errorMessage;", + " }", + " }", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/server/McpAsyncServerExchange.java": strings.Join([]string{ + "package io.modelcontextprotocol.server;", + "", + "public class McpAsyncServerExchange {", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/server/McpInitRequestHandler.java": strings.Join([]string{ + "package io.modelcontextprotocol.server;", + "", + "import io.modelcontextprotocol.spec.McpSchema;", + "import reactor.core.publisher.Mono;", + "", + "public interface McpInitRequestHandler {", + " Mono handle(McpSchema.InitializeRequest request);", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/server/McpNotificationHandler.java": strings.Join([]string{ + "package io.modelcontextprotocol.server;", + "", + "import reactor.core.publisher.Mono;", + "", + "public interface McpNotificationHandler {", + " Mono handle(McpAsyncServerExchange exchange, Object params);", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/server/McpRequestHandler.java": strings.Join([]string{ + "package io.modelcontextprotocol.server;", + "", + "import reactor.core.publisher.Mono;", + "", + "public interface McpRequestHandler {", + " Mono handle(McpAsyncServerExchange exchange, Object params);", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/spec/McpServerTransport.java": strings.Join([]string{ + "package io.modelcontextprotocol.spec;", + "", + "public interface McpServerTransport {", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/spec/McpServerTransportProvider.java": strings.Join([]string{ + "package io.modelcontextprotocol.spec;", + "", + "import java.util.List;", + "", + "public interface McpServerTransportProvider {", + " default List protocolVersions() {", + " return List.of(\"2024-11-05\");", + " }", + "", + " void setSessionFactory(McpServerSession.Factory sessionFactory);", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/spec/McpServerSession.java": strings.Join([]string{ + "package io.modelcontextprotocol.spec;", + "", + "import java.time.Duration;", + "import java.util.Map;", + "", + "import io.modelcontextprotocol.server.McpInitRequestHandler;", + "import io.modelcontextprotocol.server.McpNotificationHandler;", + "import io.modelcontextprotocol.server.McpRequestHandler;", + "", + "public class McpServerSession {", + " public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport, McpInitRequestHandler initHandler, Map> requestHandlers, Map notificationHandlers) {", + " }", + "", + " @FunctionalInterface", + " public interface Factory {", + " McpServerSession create(McpServerTransport sessionTransport);", + " }", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/spec/McpError.java": strings.Join([]string{ + "package io.modelcontextprotocol.spec;", + "", + "public class McpError extends RuntimeException {", + " public McpError(String message) {", + " super(message);", + " }", + "", + " public static Builder builder(int code) {", + " return new Builder(code);", + " }", + "", + " public static final class Builder {", + " private final int code;", + " private String message;", + "", + " private Builder(int code) {", + " this.code = code;", + " }", + "", + " public Builder message(String message) {", + " this.message = message;", + " return this;", + " }", + "", + " public McpError build() {", + " return new McpError(this.message == null ? String.valueOf(this.code) : this.message);", + " }", + " }", + "}", + "", + }, "\n"), + "io/modelcontextprotocol/spec/McpSchema.java": strings.Join([]string{ + "package io.modelcontextprotocol.spec;", + "", + "import java.util.List;", + "import java.util.Map;", + "", + "public final class McpSchema {", + " private McpSchema() {", + " }", + "", + " public static final String METHOD_TOOLS_LIST = \"tools/list\";", + " public static final String METHOD_TOOLS_CALL = \"tools/call\";", + "", + " public static final class ErrorCodes {", + " public static final int INVALID_PARAMS = -32602;", + " }", + "", + " public static final class InitializeRequest {", + " }", + "", + " public static final class InitializeResult {", + " public InitializeResult(String protocolVersion, ServerCapabilities capabilities, Implementation serverInfo, String instructions) {", + " }", + " }", + "", + " public static final class Implementation {", + " public Implementation(String name, String title, String version) {", + " }", + " }", + "", + " public static final class ServerCapabilities {", + " public ServerCapabilities(Object completions, Map experimental, Object logging, Object prompts, Object resources, ToolCapabilities tools) {", + " }", + "", + " public static final class ToolCapabilities {", + " public ToolCapabilities(Boolean listChanged) {", + " }", + " }", + " }", + "", + " public static final class CallToolRequest {", + " private final String name;", + " private final Map arguments;", + "", + " public CallToolRequest() {", + " this(null, null);", + " }", + "", + " public CallToolRequest(String name, Map arguments) {", + " this.name = name;", + " this.arguments = arguments;", + " }", + "", + " public String name() {", + " return this.name;", + " }", + "", + " public Map arguments() {", + " return this.arguments;", + " }", + " }", + "", + " public static final class CallToolResult {", + " public static Builder builder() {", + " return new Builder();", + " }", + "", + " public static final class Builder {", + " public Builder addTextContent(String text) {", + " return this;", + " }", + "", + " public Builder structuredContent(Object structuredContent) {", + " return this;", + " }", + "", + " public Builder isError(Boolean isError) {", + " return this;", + " }", + "", + " public CallToolResult build() {", + " return new CallToolResult();", + " }", + " }", + " }", + "}", + "", + }, "\n"), + "reactor/core/publisher/Mono.java": strings.Join([]string{ + "package reactor.core.publisher;", + "", + "public class Mono {", + " public static Mono just(T value) {", + " return new Mono<>();", + " }", + "", + " public static Mono error(Throwable error) {", + " return new Mono<>();", + " }", + "}", + "", + }, "\n"), + } + + for relativePath, content := range stubs { + writeJavaTestSource(t, root, relativePath, []byte(content)) + } +} + func diffBytes(want []byte, got []byte) string { return fmt.Sprintf("--- want\n+++ got\n%s", firstDiffLine(string(want), string(got))) } diff --git a/internal/codegen/java_contract_test.go b/internal/codegen/java_contract_test.go new file mode 100644 index 0000000..9fa49e5 --- /dev/null +++ b/internal/codegen/java_contract_test.go @@ -0,0 +1,298 @@ +package codegen + +import ( + "strings" + "testing" + + "google.golang.org/protobuf/compiler/protogen" +) + +func TestJavaContract_PublicAPIAndRegistrationShape(t *testing.T) { + generated := renderBasicJavaFixture(t) + + wantSnippets := []string{ + "public final class ExampleMcp {", + "public interface ExampleAPIToolHandler {", + "CreateReportResponse createReport(McpAsyncServerExchange ctx, CreateReportRequest request) throws Exception;", + "public static void registerExampleAPITools(", + "McpServerTransportProvider transportProvider,", + } + assertJavaContains(t, generated, wantSnippets...) + + notWantSnippets := []string{ + "addTool(", + "DynamicMessage", + } + assertJavaOmits(t, generated, notWantSnippets...) +} + +func TestJavaContract_OnePublicSidecarClassAndImports(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/shared.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.shared.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/shared;sharedv1";`, + `option java_package = "com.example.shared";`, + `option java_outer_classname = "SharedTypes";`, + `message SharedRequest {}`, + `message SharedResponse {}`, + ``, + }, "\n"), + "test/v1/shared_multi.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.sharedmulti.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/sharedmulti;sharedmultiv1";`, + `option java_package = "com.example.sharedmulti";`, + `option java_multiple_files = true;`, + `message MultiRequest {}`, + `message MultiResponse {}`, + ``, + }, "\n"), + "test/v1/shared_default_outer.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.defaultouter.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/defaultouter;defaultouterv1";`, + `option java_package = "com.example.defaultouter";`, + `message DefaultRequest {}`, + `message DefaultResponse {}`, + ``, + }, "\n"), + "test/v1/service.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.service.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/service;servicev1";`, + `option java_package = "com.example.service";`, + `option java_multiple_files = true;`, + `import "test/v1/shared.proto";`, + `import "test/v1/shared_multi.proto";`, + `import "test/v1/shared_default_outer.proto";`, + `service CrossFileAPI {`, + ` rpc UseShared(test.shared.v1.SharedRequest) returns (test.shared.v1.SharedResponse);`, + ` rpc UseMulti(test.sharedmulti.v1.MultiRequest) returns (test.sharedmulti.v1.MultiResponse);`, + ` rpc UseDefault(test.defaultouter.v1.DefaultRequest) returns (test.defaultouter.v1.DefaultResponse);`, + `}`, + ``, + }, "\n"), + }, "test/v1/service.proto") + + generated := renderJavaFileFromPlugin(t, plugin, "test/v1/service.proto") + wantSnippets := []string{ + "package com.example.service;", + "import com.example.shared.SharedTypes;", + "import com.example.sharedmulti.MultiRequest;", + "import com.example.sharedmulti.MultiResponse;", + "import com.example.defaultouter.SharedDefaultOuter;", + "public final class ServiceMcp {", + "SharedTypes.SharedRequest", + "MultiRequest", + "SharedDefaultOuter.DefaultRequest", + } + assertJavaContains(t, generated, wantSnippets...) +} + +func TestJavaContract_LowLevelServerContractShape(t *testing.T) { + generated := renderBasicJavaFixture(t) + + wantSnippets := []string{ + "private static final class RegisteredTool {", + "private static final class ServerToolRegistry {", + "transportProvider.setSessionFactory(", + "new McpServerSession(", + "requestHandlers.put(McpSchema.METHOD_TOOLS_LIST", + "requestHandlers.put(McpSchema.METHOD_TOOLS_CALL", + } + assertJavaContains(t, generated, wantSnippets...) + + notWantSnippets := []string{ + ".tools(", + ".toolCall(", + "addTool(", + } + assertJavaOmits(t, generated, notWantSnippets...) +} + +func TestJavaContract_RawSchemaProtoJSONAndValidation(t *testing.T) { + generated := renderBasicJavaFixture(t) + + wantSnippets := []string{ + "private static final JsonFormat.TypeRegistry PROTO_TYPE_REGISTRY = buildTypeRegistry();", + "private static JsonFormat.TypeRegistry buildTypeRegistry() {", + "registerFileTypes(builder, seenFiles, Example.getDescriptor());", + "builder.add(descriptor);", + "private static Map loadSchema(String rawSchemaJson) {", + "private static void validateJson(Map schema, Object payload) {", + "private static Message parseProtoJson(String argumentsJson, Message.Builder builder) {", + "private static String marshalProtoJson(Message responseMessage) {", + "private static Mono dispatchToolCall(", + "validateJson(tool.inputSchema, arguments);", + "parseProtoJson(JSON_MAPPER.writeValueAsString(arguments), tool.requestBuilder.get())", + "marshalProtoJson(responseMessage);", + "validateJson(tool.outputSchema, structuredContent);", + "JsonFormat.parser().usingTypeRegistry(PROTO_TYPE_REGISTRY).merge(", + "usingTypeRegistry(PROTO_TYPE_REGISTRY)", + "alwaysPrintFieldsWithNoPresence()", + "omittingInsignificantWhitespace()", + "McpSchema.ErrorCodes.INVALID_PARAMS", + ".isError(true)", + } + assertJavaContains(t, generated, wantSnippets...) +} + +func TestJavaContract_MetadataProjection(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/metadata.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.metadata.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/metadata;metadatav1";`, + `option java_package = "com.example.metadata";`, + `option java_multiple_files = true;`, + `import "mcp/options/v1/options.proto";`, + `message MetadataRequest {}`, + `message MetadataResponse {}`, + `service MetadataAPI {`, + ` rpc OptionalTask(MetadataRequest) returns (MetadataResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` annotations: { read_only_hint: true open_world_hint: false }`, + ` icons: [{ src: "https://example.com/light.svg" mime_type: "image/svg+xml" sizes: "64x64" theme: "light" }]`, + ` execution: { task_support: TASK_SUPPORT_OPTIONAL }`, + ` };`, + ` }`, + ` rpc RequiredTask(MetadataRequest) returns (MetadataResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` icons: [{ src: "https://example.com/dark.svg" mime_type: "image/svg+xml" sizes: "64x64" theme: "dark" }]`, + ` execution: { task_support: TASK_SUPPORT_REQUIRED }`, + ` };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/metadata.proto") + + generated := renderJavaFileFromPlugin(t, plugin, "test/v1/metadata.proto") + wantSnippets := []string{ + "private static Map listRegisteredTools(ServerToolRegistry registry) {", + "private static Map toolAsProtocolMap(RegisteredTool tool) {", + "new LinkedHashMap<>()", + "toolAnnotationsMapOrNull(", + "toolExecutionMapOrNull(", + "iconThemeOrError(", + `"icons"`, + `"execution"`, + `"taskSupport"`, + `"light"`, + `"dark"`, + } + assertJavaContains(t, generated, wantSnippets...) + assertJavaOmits(t, generated, "Tool.builder(") +} + +func TestJavaContract_MetadataProjectionRejectsUnknownIconTheme(t *testing.T) { + invalidPlugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/invalid_theme.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.invalidtheme.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/invalidtheme;invalidthemev1";`, + `option java_package = "com.example.invalidtheme";`, + `option java_multiple_files = true;`, + `import "mcp/options/v1/options.proto";`, + `message InvalidThemeRequest {}`, + `message InvalidThemeResponse {}`, + `service InvalidThemeAPI {`, + ` rpc BadIcon(InvalidThemeRequest) returns (InvalidThemeResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` icons: [{ src: "https://example.com/icon.svg" theme: "solarized" }]`, + ` };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/invalid_theme.proto") + invalidFile := invalidPlugin.FilesByPath["test/v1/invalid_theme.proto"] + if invalidFile == nil { + t.Fatal("invalid theme proto file not found in plugin") + } + shared, err := CollectFileModel(invalidFile, Options{Language: LanguageJava}) + if err != nil { + t.Fatalf("CollectFileModel invalid theme: %v", err) + } + jvm, err := CollectJVMFileModel(invalidFile, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel invalid theme: %v", err) + } + err = renderJavaFile(invalidPlugin, jvm) + if err == nil { + t.Fatal("renderJavaFile unexpectedly succeeded for invalid icon theme") + } + if !strings.Contains(err.Error(), `unsupported Java icon theme "solarized"`) { + t.Fatalf("renderJavaFile error = %v, want invalid theme rejection", err) + } + if got := len(invalidPlugin.Response().GetFile()); got != 0 { + t.Fatalf("invalid theme emitted %d files before failing, want 0", got) + } +} + +func renderBasicJavaFixture(t *testing.T) string { + t.Helper() + + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/example.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.example.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/example;examplev1";`, + `option java_package = "com.example.contract";`, + `option java_multiple_files = true;`, + `import "mcp/options/v1/options.proto";`, + `message CreateReportRequest { string title = 1; }`, + `message CreateReportResponse { string report_id = 1; }`, + `service ExampleAPI {`, + ` option (mcp.options.v1.service) = { namespace: "example" };`, + ` rpc CreateReport(CreateReportRequest) returns (CreateReportResponse) {`, + ` option (mcp.options.v1.method) = { title: "Create report" };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/example.proto") + + return renderJavaFileFromPlugin(t, plugin, "test/v1/example.proto") +} + +func renderJavaFileFromPlugin(t *testing.T, plugin *protogen.Plugin, protoPath string) string { + t.Helper() + + file := plugin.FilesByPath[protoPath] + if file == nil { + t.Fatalf("proto file %q not found in plugin", protoPath) + } + shared, err := CollectFileModel(file, Options{Language: LanguageJava}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + jvm, err := CollectJVMFileModel(file, shared) + if err != nil { + t.Fatalf("CollectJVMFileModel: %v", err) + } + if err := renderJavaFile(plugin, jvm); err != nil { + t.Fatalf("renderJavaFile: %v", err) + } + + return string(generatedFileContent(t, plugin, javaSidecarOutputPath(jvmGeneratedFilenamePrefixForProtoPath(protoPath)))) +} + +func assertJavaContains(t *testing.T, generated string, snippets ...string) { + t.Helper() + for _, snippet := range snippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated Java missing snippet %q\n%s", snippet, generated) + } + } +} + +func assertJavaOmits(t *testing.T, generated string, snippets ...string) { + t.Helper() + for _, snippet := range snippets { + if strings.Contains(generated, snippet) { + t.Fatalf("generated Java unexpectedly contains snippet %q\n%s", snippet, generated) + } + } +} diff --git a/internal/codegen/kotlin_contract_test.go b/internal/codegen/kotlin_contract_test.go index 9dc00d7..26e893c 100644 --- a/internal/codegen/kotlin_contract_test.go +++ b/internal/codegen/kotlin_contract_test.go @@ -22,7 +22,6 @@ func TestKotlinContract_PublicAPIAndRegistrationShape(t *testing.T) { "server." + "addTool(", "addTool" + "(name =", "Dynamic" + "Message", - "Descr" + "iptor", } assertKotlinOmits(t, generated, notWantSnippets...) } @@ -131,11 +130,19 @@ func TestKotlinContract_UsesNetworkntJsonSchemaValidation(t *testing.T) { generated := renderBasicKotlinFixture(t) wantSnippets := []string{ + "import com.google.protobuf.Descriptors", "import com.networknt.schema.InputFormat", "import com.networknt.schema.JsonSchemaFactory", "import com.networknt.schema.SpecVersion", "private val jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)", + "private val protoTypeRegistry = buildTypeRegistry()", + "registerFileTypes(builder, seenFiles, Example.getDescriptor())", + "builder.add(descriptor)", + "JsonFormat.parser().usingTypeRegistry(protoTypeRegistry).merge(payload, builder)", + "JsonFormat.printer().usingTypeRegistry(protoTypeRegistry).includingDefaultValueFields().print(message)", "jsonSchemaFactory.getSchema(rawSchemaJson).validate(payload.toString(), InputFormat.JSON)", + `schema["\$defs"]?.jsonObject`, + "ToolSchema(properties = properties, required = required, defs = defs)", `invalidParams(tool.name, error.message ?: error.toString())`, `IllegalStateException("mcpruntime: validate output for tool '${tool.name}': ${error.message}", error)`, } diff --git a/internal/codegen/render_java.go b/internal/codegen/render_java.go new file mode 100644 index 0000000..d70b2ed --- /dev/null +++ b/internal/codegen/render_java.go @@ -0,0 +1,892 @@ +package codegen + +import ( + "fmt" + "path" + "sort" + "strings" + "unicode" + + mcpoptionsv1 "github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func renderJavaFile(plugin *protogen.Plugin, model JVMFileModel) error { + if model.Language != LanguageJava { + return fmt.Errorf("java renderer requires lang=java, got %q", model.Language) + } + if err := validateJavaMetadata(model); err != nil { + return err + } + + info, err := newJavaRenderInfo(plugin, model) + if err != nil { + return err + } + + filePackage, err := resolveJVMFilePackage(info.file) + if err != nil { + return err + } + + imports := map[string]bool{} + for _, service := range model.Services { + for _, method := range service.Methods { + if _, err := info.javaTypeExpr(method.Input, imports, filePackage.Package); err != nil { + return err + } + if _, err := info.javaTypeExpr(method.Output, imports, filePackage.Package); err != nil { + return err + } + } + } + descriptorAccessors, err := collectJavaDescriptorAccessors(info.file.Desc, filePackage.Package) + if err != nil { + return err + } + for _, accessor := range descriptorAccessors { + if accessor.ImportPath != "" { + imports[accessor.ImportPath] = true + } + } + + className := javaSidecarClassName(model.GeneratedFilenamePrefix) + filename := javaSidecarOutputPath(model.GeneratedFilenamePrefix) + generated := plugin.NewGeneratedFile(filename, "") + + generated.P("// Code generated by protoc-gen-mcp. DO NOT EDIT.") + generated.P("// source: ", model.ProtoPath) + generated.P() + if filePackage.Package != "" { + generated.P("package ", filePackage.Package, ";") + generated.P() + } + + javaImports := []string{ + "com.google.protobuf.Message", + "com.google.protobuf.util.JsonFormat", + "com.google.protobuf.Descriptors", + "io.modelcontextprotocol.json.McpJsonDefaults", + "io.modelcontextprotocol.json.McpJsonMapper", + "io.modelcontextprotocol.json.TypeRef", + "io.modelcontextprotocol.json.schema.JsonSchemaValidator", + "io.modelcontextprotocol.server.McpAsyncServerExchange", + "io.modelcontextprotocol.server.McpInitRequestHandler", + "io.modelcontextprotocol.server.McpNotificationHandler", + "io.modelcontextprotocol.server.McpRequestHandler", + "io.modelcontextprotocol.spec.McpError", + "io.modelcontextprotocol.spec.McpSchema", + "io.modelcontextprotocol.spec.McpServerSession", + "io.modelcontextprotocol.spec.McpServerTransport", + "io.modelcontextprotocol.spec.McpServerTransportProvider", + "java.io.IOException", + "java.time.Duration", + "java.util.ArrayList", + "java.util.Collection", + "java.util.Collections", + "java.util.HashSet", + "java.util.LinkedHashMap", + "java.util.List", + "java.util.Map", + "java.util.Set", + "java.util.UUID", + "java.util.WeakHashMap", + "java.util.function.Supplier", + "reactor.core.publisher.Mono", + } + for importPath := range imports { + javaImports = append(javaImports, importPath) + } + sort.Strings(javaImports) + for _, importPath := range javaImports { + generated.P("import ", importPath, ";") + } + generated.P() + + generated.P("public final class ", className, " {") + generated.P(" private ", className, "() {") + generated.P(" }") + generated.P() + + for serviceIdx, service := range model.Services { + generated.P(" public interface ", service.HandlerName, " {") + for _, method := range service.Methods { + inputType, err := info.javaTypeExpr(method.Input, imports, filePackage.Package) + if err != nil { + return err + } + outputType, err := info.javaTypeExpr(method.Output, imports, filePackage.Package) + if err != nil { + return err + } + generated.P(" ", outputType, " ", method.MethodName, "(McpAsyncServerExchange ctx, ", inputType, " request) throws Exception;") + } + generated.P(" }") + generated.P() + + generated.P(" public static void ", service.RegisterName, "(") + generated.P(" McpServerTransportProvider transportProvider,") + generated.P(" ", service.HandlerName, " impl,") + generated.P(" String namespace) {") + generated.P(" if (transportProvider == null) {") + generated.P(" throw new IllegalArgumentException(\"transportProvider must not be null\");") + generated.P(" }") + generated.P(" if (impl == null) {") + generated.P(" throw new IllegalArgumentException(\"impl must not be null\");") + generated.P(" }") + generated.P(" ServerToolRegistry registry = registryFor(transportProvider);") + generated.P(" String resolvedNamespace = normalizeNamespace(namespace, ", quote(service.Namespace), ");") + for _, method := range service.Methods { + inputType, err := info.javaTypeExpr(method.Input, imports, filePackage.Package) + if err != nil { + return err + } + outputType, err := info.javaTypeExpr(method.Output, imports, filePackage.Package) + if err != nil { + return err + } + icons, err := javaIcons(method.Icons) + if err != nil { + return err + } + generated.P(" registry.registerTool(") + generated.P(" new RegisteredTool(") + generated.P(" toolName(resolvedNamespace, ", quote(method.ToolName), "),") + generated.P(" ", javaNullableString(method.Title), ",") + generated.P(" ", javaNullableString(method.Description), ",") + generated.P(" ", method.SchemaConst, "_INPUT_SCHEMA_JSON,") + generated.P(" ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON,") + generated.P(" () -> ", inputType, ".newBuilder(),") + generated.P(" () -> ", outputType, ".newBuilder(),") + generated.P(" (ctx, request) -> impl.", method.MethodName, "(ctx, (", inputType, ") request),") + generated.P(" ", javaAnnotations(method.Annotations), ",") + generated.P(" ", icons, ",") + generated.P(" ", javaTaskSupport(method.TaskSupport), ")") + generated.P(" );") + } + generated.P(" installSessionFactory(transportProvider, registry);") + generated.P(" }") + generated.P() + + for methodIdx, method := range service.Methods { + generated.P(" private static final String ", method.SchemaConst, "_INPUT_SCHEMA_JSON = ", quote(method.InputSchemaJSON), ";") + generated.P() + generated.P(" private static final String ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON = ", quote(method.OutputSchemaJSON), ";") + if methodIdx < len(service.Methods)-1 || serviceIdx < len(model.Services)-1 { + generated.P() + } + } + } + + if len(model.Services) > 0 { + generated.P() + } + renderJavaRuntime(generated, descriptorAccessors) + generated.P("}") + + return nil +} + +type javaDescriptorAccessor struct { + ImportPath string + Expr string +} + +type javaRenderInfo struct { + file *protogen.File + messages map[string]*protogen.Message + enums map[string]*protogen.Enum +} + +func newJavaRenderInfo(plugin *protogen.Plugin, model JVMFileModel) (javaRenderInfo, error) { + file := plugin.FilesByPath[model.ProtoPath] + if file == nil { + return javaRenderInfo{}, fmt.Errorf("generated file %q not found in plugin", model.ProtoPath) + } + + info := javaRenderInfo{ + file: file, + messages: map[string]*protogen.Message{}, + enums: map[string]*protogen.Enum{}, + } + for _, current := range plugin.Files { + indexJavaMessages(info.messages, current.Messages) + indexJavaEnums(info.enums, current.Enums) + indexJavaEnumsInMessages(info.enums, current.Messages) + } + + return info, nil +} + +func indexJavaMessages(index map[string]*protogen.Message, messages []*protogen.Message) { + for _, message := range messages { + index[string(message.Desc.FullName())] = message + indexJavaMessages(index, message.Messages) + } +} + +func indexJavaEnums(index map[string]*protogen.Enum, enums []*protogen.Enum) { + for _, enum := range enums { + index[string(enum.Desc.FullName())] = enum + } +} + +func indexJavaEnumsInMessages(index map[string]*protogen.Enum, messages []*protogen.Message) { + for _, message := range messages { + indexJavaEnums(index, message.Enums) + indexJavaEnumsInMessages(index, message.Messages) + } +} + +func (j javaRenderInfo) javaTypeExpr(ref JVMTypeRef, imports map[string]bool, currentPackage string) (string, error) { + var ( + resolved jvmResolvedTypeRef + err error + ) + + if ref.IsEnum { + enum := j.enums[ref.ProtoFullName] + if enum == nil { + return "", fmt.Errorf("enum %q not found during Java render", ref.ProtoFullName) + } + resolved, err = resolveJVMEnumTypeRef(enum, j.file.Desc.Path()) + if err != nil { + return "", err + } + } else { + message := j.messages[ref.ProtoFullName] + if message == nil { + return "", fmt.Errorf("message %q not found during Java render", ref.ProtoFullName) + } + resolved, err = resolveJVMMessageTypeRef(message, j.file.Desc.Path()) + if err != nil { + return "", err + } + } + + if resolved.ImportPath != "" && javaImportPackage(resolved.ImportPath) != currentPackage { + imports[resolved.ImportPath] = true + } + return resolved.Expr, nil +} + +func renderJavaRuntime(generated *protogen.GeneratedFile, descriptorAccessors []javaDescriptorAccessor) { + generated.P(" private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofMinutes(5);") + generated.P(" private static final TypeRef> MAP_TYPE_REF = new TypeRef<>() {") + generated.P(" };") + generated.P(" private static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper();") + generated.P(" private static final JsonSchemaValidator JSON_SCHEMA_VALIDATOR = McpJsonDefaults.getSchemaValidator();") + generated.P(" private static final JsonFormat.TypeRegistry PROTO_TYPE_REGISTRY = buildTypeRegistry();") + generated.P(" private static final Map SERVER_REGISTRIES = Collections.synchronizedMap(new WeakHashMap<>());") + generated.P() + generated.P(" private static JsonFormat.TypeRegistry buildTypeRegistry() {") + generated.P(" JsonFormat.TypeRegistry.Builder builder = JsonFormat.TypeRegistry.newBuilder();") + generated.P(" Set seenFiles = new HashSet<>();") + for _, accessor := range descriptorAccessors { + generated.P(" registerFileTypes(builder, seenFiles, ", accessor.Expr, ");") + } + generated.P(" return builder.build();") + generated.P(" }") + generated.P() + generated.P(" private static void registerFileTypes(") + generated.P(" JsonFormat.TypeRegistry.Builder builder,") + generated.P(" Set seenFiles,") + generated.P(" Descriptors.FileDescriptor file) {") + generated.P(" if (!seenFiles.add(file.getFullName())) {") + generated.P(" return;") + generated.P(" }") + generated.P(" for (Descriptors.Descriptor descriptor : file.getMessageTypes()) {") + generated.P(" registerMessageType(builder, descriptor);") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static void registerMessageType(") + generated.P(" JsonFormat.TypeRegistry.Builder builder,") + generated.P(" Descriptors.Descriptor descriptor) {") + generated.P(" builder.add(descriptor);") + generated.P(" for (Descriptors.Descriptor nested : descriptor.getNestedTypes()) {") + generated.P(" registerMessageType(builder, nested);") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" @FunctionalInterface") + generated.P(" private interface ToolInvoker {") + generated.P(" Message handle(McpAsyncServerExchange exchange, Message request) throws Exception;") + generated.P(" }") + generated.P() + generated.P(" private static final class RegisteredTool {") + generated.P(" private final String name;") + generated.P(" private final String title;") + generated.P(" private final String description;") + generated.P(" private final Map inputSchema;") + generated.P(" private final Map outputSchema;") + generated.P(" private final Supplier requestBuilder;") + generated.P(" private final Supplier responseBuilder;") + generated.P(" private final ToolInvoker handler;") + generated.P(" private final Map annotations;") + generated.P(" private final List> icons;") + generated.P(" private final Map execution;") + generated.P() + generated.P(" private RegisteredTool(") + generated.P(" String name,") + generated.P(" String title,") + generated.P(" String description,") + generated.P(" String inputSchemaJson,") + generated.P(" String outputSchemaJson,") + generated.P(" Supplier requestBuilder,") + generated.P(" Supplier responseBuilder,") + generated.P(" ToolInvoker handler,") + generated.P(" Map annotations,") + generated.P(" List> icons,") + generated.P(" Map execution) {") + generated.P(" this.name = name;") + generated.P(" this.title = title;") + generated.P(" this.description = description;") + generated.P(" this.inputSchema = loadSchema(inputSchemaJson);") + generated.P(" this.outputSchema = loadSchema(outputSchemaJson);") + generated.P(" this.requestBuilder = requestBuilder;") + generated.P(" this.responseBuilder = responseBuilder;") + generated.P(" this.handler = handler;") + generated.P(" this.annotations = annotations;") + generated.P(" this.icons = icons;") + generated.P(" this.execution = execution;") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static final class ServerToolRegistry {") + generated.P(" private final LinkedHashMap toolsByName = new LinkedHashMap<>();") + generated.P(" private boolean sessionFactoryInstalled;") + generated.P() + generated.P(" private synchronized void registerTool(RegisteredTool tool) {") + generated.P(" if (this.toolsByName.containsKey(tool.name)) {") + generated.P(" throw new IllegalStateException(\"duplicate tool registration: \" + tool.name);") + generated.P(" }") + generated.P(" this.toolsByName.put(tool.name, tool);") + generated.P(" }") + generated.P() + generated.P(" private synchronized RegisteredTool tool(String name) {") + generated.P(" return this.toolsByName.get(name);") + generated.P(" }") + generated.P() + generated.P(" private synchronized Collection allTools() {") + generated.P(" return new ArrayList<>(this.toolsByName.values());") + generated.P(" }") + generated.P() + generated.P(" private synchronized boolean markSessionFactoryInstalled() {") + generated.P(" if (this.sessionFactoryInstalled) {") + generated.P(" return false;") + generated.P(" }") + generated.P(" this.sessionFactoryInstalled = true;") + generated.P(" return true;") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static ServerToolRegistry registryFor(McpServerTransportProvider transportProvider) {") + generated.P(" synchronized (SERVER_REGISTRIES) {") + generated.P(" ServerToolRegistry registry = SERVER_REGISTRIES.get(transportProvider);") + generated.P(" if (registry == null) {") + generated.P(" registry = new ServerToolRegistry();") + generated.P(" SERVER_REGISTRIES.put(transportProvider, registry);") + generated.P(" }") + generated.P(" return registry;") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static void installSessionFactory(McpServerTransportProvider transportProvider, ServerToolRegistry registry) {") + generated.P(" if (!registry.markSessionFactoryInstalled()) {") + generated.P(" return;") + generated.P(" }") + generated.P(" transportProvider.setSessionFactory(") + generated.P(" sessionTransport -> new McpServerSession(") + generated.P(" UUID.randomUUID().toString(),") + generated.P(" DEFAULT_REQUEST_TIMEOUT,") + generated.P(" sessionTransport,") + generated.P(" initializeHandler(transportProvider),") + generated.P(" requestHandlers(registry),") + generated.P(" notificationHandlers()));") + generated.P(" }") + generated.P() + generated.P(" private static McpInitRequestHandler initializeHandler(McpServerTransportProvider transportProvider) {") + generated.P(" return request -> Mono.just(") + generated.P(" new McpSchema.InitializeResult(") + generated.P(" resolveProtocolVersion(transportProvider),") + generated.P(" new McpSchema.ServerCapabilities(") + generated.P(" null,") + generated.P(" null,") + generated.P(" null,") + generated.P(" null,") + generated.P(" null,") + generated.P(" new McpSchema.ServerCapabilities.ToolCapabilities(false)),") + generated.P(" new McpSchema.Implementation(\"protoc-gen-mcp\", \"protoc-gen-mcp\", \"dev\"),") + generated.P(" null));") + generated.P(" }") + generated.P() + generated.P(" private static String resolveProtocolVersion(McpServerTransportProvider transportProvider) {") + generated.P(" List protocolVersions = transportProvider.protocolVersions();") + generated.P(" if (protocolVersions == null || protocolVersions.isEmpty()) {") + generated.P(" return \"2024-11-05\";") + generated.P(" }") + generated.P(" return protocolVersions.get(0);") + generated.P(" }") + generated.P() + generated.P(" private static Map> requestHandlers(ServerToolRegistry registry) {") + generated.P(" Map> requestHandlers = new LinkedHashMap<>();") + generated.P(" requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, (exchange, params) -> Mono.just(listRegisteredTools(registry)));") + generated.P(" requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, (exchange, params) -> dispatchToolCall(registry, exchange, params));") + generated.P(" return requestHandlers;") + generated.P(" }") + generated.P() + generated.P(" private static Map notificationHandlers() {") + generated.P(" return new LinkedHashMap<>();") + generated.P(" }") + generated.P() + generated.P(" private static Map listRegisteredTools(ServerToolRegistry registry) {") + generated.P(" Map result = new LinkedHashMap<>();") + generated.P(" List> tools = new ArrayList<>();") + generated.P(" for (RegisteredTool tool : registry.allTools()) {") + generated.P(" tools.add(toolAsProtocolMap(tool));") + generated.P(" }") + generated.P(" result.put(\"tools\", tools);") + generated.P(" return result;") + generated.P(" }") + generated.P() + generated.P(" private static Map toolAsProtocolMap(RegisteredTool tool) {") + generated.P(" Map protocol = new LinkedHashMap<>();") + generated.P(" protocol.put(\"name\", tool.name);") + generated.P(" putIfNotNull(protocol, \"title\", tool.title);") + generated.P(" putIfNotNull(protocol, \"description\", tool.description);") + generated.P(" protocol.put(\"inputSchema\", tool.inputSchema);") + generated.P(" protocol.put(\"outputSchema\", tool.outputSchema);") + generated.P(" putIfNotNull(protocol, \"annotations\", tool.annotations);") + generated.P(" if (!tool.icons.isEmpty()) {") + generated.P(" protocol.put(\"icons\", tool.icons);") + generated.P(" }") + generated.P(" putIfNotNull(protocol, \"execution\", tool.execution);") + generated.P(" return protocol;") + generated.P(" }") + generated.P() + generated.P(" private static void putIfNotNull(Map target, String key, Object value) {") + generated.P(" if (value != null) {") + generated.P(" target.put(key, value);") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static Map toolAnnotationsMapOrNull(") + generated.P(" String title,") + generated.P(" Boolean readOnlyHint,") + generated.P(" Boolean destructiveHint,") + generated.P(" Boolean idempotentHint,") + generated.P(" Boolean openWorldHint) {") + generated.P(" if (title == null && readOnlyHint == null && destructiveHint == null && idempotentHint == null && openWorldHint == null) {") + generated.P(" return null;") + generated.P(" }") + generated.P(" Map annotations = new LinkedHashMap<>();") + generated.P(" putIfNotNull(annotations, \"title\", title);") + generated.P(" putIfNotNull(annotations, \"readOnlyHint\", readOnlyHint);") + generated.P(" putIfNotNull(annotations, \"destructiveHint\", destructiveHint);") + generated.P(" putIfNotNull(annotations, \"idempotentHint\", idempotentHint);") + generated.P(" putIfNotNull(annotations, \"openWorldHint\", openWorldHint);") + generated.P(" return annotations.isEmpty() ? null : annotations;") + generated.P(" }") + generated.P() + generated.P(" private static Map toolExecutionMapOrNull(String taskSupport) {") + generated.P(" if (taskSupport == null || taskSupport.isBlank()) {") + generated.P(" return null;") + generated.P(" }") + generated.P(" Map execution = new LinkedHashMap<>();") + generated.P(" execution.put(\"taskSupport\", taskSupport);") + generated.P(" return execution;") + generated.P(" }") + generated.P() + generated.P(" private static Map iconMap(String src, String mimeType, List sizes, String theme) {") + generated.P(" Map icon = new LinkedHashMap<>();") + generated.P(" icon.put(\"src\", src);") + generated.P(" putIfNotNull(icon, \"mimeType\", mimeType);") + generated.P(" if (sizes != null && !sizes.isEmpty()) {") + generated.P(" icon.put(\"sizes\", sizes);") + generated.P(" }") + generated.P(" putIfNotNull(icon, \"theme\", iconThemeOrError(theme));") + generated.P(" return icon;") + generated.P(" }") + generated.P() + generated.P(" private static String iconThemeOrError(String rawTheme) {") + generated.P(" if (rawTheme == null || rawTheme.isBlank()) {") + generated.P(" return null;") + generated.P(" }") + generated.P(" if (\"light\".equals(rawTheme) || \"dark\".equals(rawTheme)) {") + generated.P(" return rawTheme;") + generated.P(" }") + generated.P(" throw new IllegalArgumentException(\"unsupported Java icon theme \\\"\" + rawTheme + \"\\\"\");") + generated.P(" }") + generated.P() + generated.P(" private static Map loadSchema(String rawSchemaJson) {") + generated.P(" try {") + generated.P(" return JSON_MAPPER.readValue(rawSchemaJson, MAP_TYPE_REF);") + generated.P(" }") + generated.P(" catch (IOException error) {") + generated.P(" throw new IllegalArgumentException(\"Invalid schema: \" + rawSchemaJson, error);") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static void validateJson(Map schema, Object payload) {") + generated.P(" JsonSchemaValidator.ValidationResponse validation = JSON_SCHEMA_VALIDATOR.validate(schema, payload);") + generated.P(" if (!validation.valid()) {") + generated.P(" throw new IllegalArgumentException(validation.errorMessage());") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static Message parseProtoJson(String argumentsJson, Message.Builder builder) {") + generated.P(" try {") + generated.P(" JsonFormat.parser().usingTypeRegistry(PROTO_TYPE_REGISTRY).merge(argumentsJson, builder);") + generated.P(" return builder.build();") + generated.P(" }") + generated.P(" catch (Exception error) {") + generated.P(" throw new IllegalArgumentException(error.getMessage(), error);") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static String marshalProtoJson(Message responseMessage) {") + generated.P(" try {") + generated.P(" return JsonFormat.printer()") + generated.P(" .usingTypeRegistry(PROTO_TYPE_REGISTRY)") + generated.P(" .alwaysPrintFieldsWithNoPresence()") + generated.P(" .omittingInsignificantWhitespace()") + generated.P(" .print(responseMessage);") + generated.P(" }") + generated.P(" catch (Exception error) {") + generated.P(" throw new IllegalStateException(error.getMessage(), error);") + generated.P(" }") + generated.P(" }") + generated.P() + generated.P(" private static Mono dispatchToolCall(") + generated.P(" ServerToolRegistry registry,") + generated.P(" McpAsyncServerExchange exchange,") + generated.P(" Object params) {") + generated.P(" final McpSchema.CallToolRequest request;") + generated.P(" try {") + generated.P(" request = JSON_MAPPER.convertValue(params, McpSchema.CallToolRequest.class);") + generated.P(" }") + generated.P(" catch (Exception error) {") + generated.P(" return Mono.error(invalidParams(\"\", error));") + generated.P(" }") + generated.P() + generated.P(" String requestedToolName = request.name() == null ? \"\" : request.name();") + generated.P(" RegisteredTool tool = registry.tool(requestedToolName);") + generated.P(" if (tool == null) {") + generated.P(" return Mono.error(invalidParams(requestedToolName, \"unknown tool\"));") + generated.P(" }") + generated.P() + generated.P(" Map arguments = request.arguments() == null ? new LinkedHashMap<>() : request.arguments();") + generated.P(" try {") + generated.P(" validateJson(tool.inputSchema, arguments);") + generated.P(" }") + generated.P(" catch (Exception error) {") + generated.P(" return Mono.error(invalidParams(requestedToolName, error));") + generated.P(" }") + generated.P() + generated.P(" final Message requestMessage;") + generated.P(" try {") + generated.P(" requestMessage = parseProtoJson(JSON_MAPPER.writeValueAsString(arguments), tool.requestBuilder.get());") + generated.P(" }") + generated.P(" catch (Exception error) {") + generated.P(" return Mono.error(invalidParams(requestedToolName, error));") + generated.P(" }") + generated.P() + generated.P(" final Message responseMessage;") + generated.P(" try {") + generated.P(" Message handled = tool.handler.handle(exchange, requestMessage);") + generated.P(" responseMessage = handled == null ? tool.responseBuilder.get().build() : handled;") + generated.P(" }") + generated.P(" catch (Exception error) {") + generated.P(" String message = error.getMessage() == null ? error.toString() : error.getMessage();") + generated.P(" return Mono.just(toolErrorResult(message));") + generated.P(" }") + generated.P() + generated.P(" final String payload;") + generated.P(" try {") + generated.P(" payload = marshalProtoJson(responseMessage);") + generated.P(" }") + generated.P(" catch (Exception error) {") + generated.P(" throw new IllegalStateException(\"mcpruntime: marshal output for tool '\" + requestedToolName + \"': \" + error.getMessage(), error);") + generated.P(" }") + generated.P() + generated.P(" final Map structuredContent;") + generated.P(" try {") + generated.P(" structuredContent = JSON_MAPPER.readValue(payload, MAP_TYPE_REF);") + generated.P(" }") + generated.P(" catch (IOException error) {") + generated.P(" throw new IllegalStateException(\"mcpruntime: parse output for tool '\" + requestedToolName + \"': \" + error.getMessage(), error);") + generated.P(" }") + generated.P() + generated.P(" try {") + generated.P(" validateJson(tool.outputSchema, structuredContent);") + generated.P(" }") + generated.P(" catch (Exception error) {") + generated.P(" throw new IllegalStateException(\"mcpruntime: validate output for tool '\" + requestedToolName + \"': \" + error.getMessage(), error);") + generated.P(" }") + generated.P() + generated.P(" return Mono.just(") + generated.P(" McpSchema.CallToolResult.builder()") + generated.P(" .addTextContent(payload)") + generated.P(" .structuredContent(structuredContent)") + generated.P(" .build());") + generated.P(" }") + generated.P() + generated.P(" private static McpSchema.CallToolResult toolErrorResult(String message) {") + generated.P(" return McpSchema.CallToolResult.builder()") + generated.P(" .addTextContent(message)") + generated.P(" .isError(true)") + generated.P(" .build();") + generated.P(" }") + generated.P() + generated.P(" private static McpError invalidParams(String toolName, Object error) {") + generated.P(" return McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS)") + generated.P(" .message(\"invalid arguments for tool '\" + toolName + \"': \" + String.valueOf(error))") + generated.P(" .build();") + generated.P(" }") + generated.P() + generated.P(" private static String normalizeNamespace(String namespace, String defaultNamespace) {") + generated.P(" return normalizeToolSegment(namespace == null ? defaultNamespace : namespace);") + generated.P(" }") + generated.P() + generated.P(" private static String normalizeToolSegment(String segment) {") + generated.P(" if (segment == null) {") + generated.P(" return \"\";") + generated.P(" }") + generated.P(" String trimmed = segment.trim().replace('.', '_');") + generated.P(" if (trimmed.isEmpty()) {") + generated.P(" return \"\";") + generated.P(" }") + generated.P(" String[] parts = trimmed.split(\"_\");") + generated.P(" StringBuilder normalized = new StringBuilder();") + generated.P(" for (String part : parts) {") + generated.P(" if (part == null || part.isBlank()) {") + generated.P(" continue;") + generated.P(" }") + generated.P(" if (normalized.length() > 0) {") + generated.P(" normalized.append('_');") + generated.P(" }") + generated.P(" normalized.append(part);") + generated.P(" }") + generated.P(" return normalized.toString();") + generated.P(" }") + generated.P() + generated.P(" private static String toolName(String namespace, String methodName) {") + generated.P(" String normalizedNamespace = normalizeToolSegment(namespace);") + generated.P(" String normalizedMethodName = normalizeToolSegment(methodName);") + generated.P(" if (normalizedNamespace.isEmpty()) {") + generated.P(" return normalizedMethodName;") + generated.P(" }") + generated.P(" if (normalizedMethodName.isEmpty()) {") + generated.P(" return normalizedNamespace;") + generated.P(" }") + generated.P(" return normalizedNamespace + \"_\" + normalizedMethodName;") + generated.P(" }") +} + +func collectJavaDescriptorAccessors(file protoreflect.FileDescriptor, currentPackage string) ([]javaDescriptorAccessor, error) { + seen := map[string]bool{} + var accessors []javaDescriptorAccessor + var visit func(protoreflect.FileDescriptor) error + visit = func(current protoreflect.FileDescriptor) error { + if current == nil { + return nil + } + if seen[current.Path()] { + return nil + } + seen[current.Path()] = true + + if current.Path() == file.Path() || shouldIncludeJavaTypeRegistryFile(current) { + resolved, err := resolveJVMDescriptorFilePackage(current) + if err != nil { + return err + } + importPath := "" + if resolved.Package != currentPackage { + importPath = resolved.Package + "." + resolved.OuterClassName + } + accessors = append(accessors, javaDescriptorAccessor{ + ImportPath: importPath, + Expr: resolved.OuterClassName + ".getDescriptor()", + }) + } + + for i := 0; i < current.Imports().Len(); i++ { + if err := visit(current.Imports().Get(i)); err != nil { + return err + } + } + return nil + } + if err := visit(file); err != nil { + return nil, err + } + sort.Slice(accessors, func(i, j int) bool { + if accessors[i].ImportPath == accessors[j].ImportPath { + return accessors[i].Expr < accessors[j].Expr + } + return accessors[i].ImportPath < accessors[j].ImportPath + }) + return accessors, nil +} + +func shouldIncludeJavaTypeRegistryFile(file protoreflect.FileDescriptor) bool { + if file == nil { + return false + } + protoPath := file.Path() + return !strings.HasPrefix(protoPath, "google/protobuf/") && !strings.HasPrefix(protoPath, "mcp/options/") +} + +func validateJavaMetadata(model JVMFileModel) error { + for _, service := range model.Services { + for _, icon := range service.Icons { + if err := validateJavaIconTheme(icon); err != nil { + return err + } + } + for _, method := range service.Methods { + for _, icon := range method.Icons { + if err := validateJavaIconTheme(icon); err != nil { + return err + } + } + } + } + return nil +} + +func validateJavaIconTheme(icon *mcpoptionsv1.Icon) error { + switch icon.GetTheme() { + case "", "light", "dark": + return nil + default: + return fmt.Errorf("unsupported Java icon theme %q", icon.GetTheme()) + } +} + +func javaAnnotations(ann *mcpoptionsv1.ToolAnnotations) string { + if ann == nil { + return "null" + } + + return strings.Join([]string{ + "toolAnnotationsMapOrNull(", + javaNullableString(ann.Title), ", ", + javaBoolLiteralOrNull(ann.ReadOnlyHint, ann.ReadOnlyHint), ", ", + javaOptionalBoolLiteral(ann.DestructiveHint), ", ", + javaBoolLiteralOrNull(ann.IdempotentHint, ann.IdempotentHint), ", ", + javaOptionalBoolLiteral(ann.OpenWorldHint), + ")", + }, "") +} + +func javaIcons(icons []*mcpoptionsv1.Icon) (string, error) { + if len(icons) == 0 { + return "List.of()", nil + } + + items := make([]string, 0, len(icons)) + for _, icon := range icons { + if err := validateJavaIconTheme(icon); err != nil { + return "", err + } + items = append(items, fmt.Sprintf( + "iconMap(%s, %s, %s, %s)", + quote(icon.GetSrc()), + javaNullableString(icon.GetMimeType()), + javaStringListLiteral(icon.GetSizes()), + javaNullableString(icon.GetTheme()), + )) + } + return "List.of(" + strings.Join(items, ", ") + ")", nil +} + +func javaTaskSupport(taskSupport mcpoptionsv1.TaskSupport) string { + switch taskSupport { + case mcpoptionsv1.TaskSupport_TASK_SUPPORT_OPTIONAL: + return `toolExecutionMapOrNull("optional")` + case mcpoptionsv1.TaskSupport_TASK_SUPPORT_REQUIRED: + return `toolExecutionMapOrNull("required")` + default: + return `toolExecutionMapOrNull(null)` + } +} + +func javaNullableString(value string) string { + if value == "" { + return "null" + } + return quote(value) +} + +func javaOptionalBoolLiteral(value *bool) string { + if value == nil { + return "null" + } + if *value { + return "Boolean.TRUE" + } + return "Boolean.FALSE" +} + +func javaBoolLiteralOrNull(include bool, value bool) string { + if !include { + return "null" + } + if value { + return "Boolean.TRUE" + } + return "Boolean.FALSE" +} + +func javaStringListLiteral(values []string) string { + if len(values) == 0 { + return "List.of()" + } + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, quote(value)) + } + return "List.of(" + strings.Join(quoted, ", ") + ")" +} + +func javaImportPackage(importPath string) string { + idx := strings.LastIndex(importPath, ".") + if idx <= 0 { + return "" + } + return importPath[:idx] +} + +func javaSidecarOutputPath(generatedFilenamePrefix string) string { + return path.Join(path.Dir(generatedFilenamePrefix), javaSidecarClassName(generatedFilenamePrefix)+".java") +} + +func javaSidecarClassName(generatedFilenamePrefix string) string { + base := path.Base(generatedFilenamePrefix) + var b strings.Builder + nextUpper := true + for _, r := range base { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + nextUpper = true + continue + } + if b.Len() == 0 && unicode.IsDigit(r) { + b.WriteString("Proto") + } + if nextUpper { + b.WriteRune(unicode.ToUpper(r)) + nextUpper = false + continue + } + b.WriteRune(r) + } + if b.Len() == 0 { + b.WriteString("Proto") + } + b.WriteString("Mcp") + return b.String() +} diff --git a/internal/codegen/render_kotlin.go b/internal/codegen/render_kotlin.go index cc11bcf..09edc7e 100644 --- a/internal/codegen/render_kotlin.go +++ b/internal/codegen/render_kotlin.go @@ -38,6 +38,15 @@ func renderKotlinFile(plugin *protogen.Plugin, model JVMFileModel) error { } } } + descriptorAccessors, err := collectJavaDescriptorAccessors(info.file.Desc, filePackage.Package) + if err != nil { + return err + } + for _, accessor := range descriptorAccessors { + if accessor.ImportPath != "" { + imports[accessor.ImportPath] = true + } + } filename := model.GeneratedFilenamePrefix + "_mcp.kt" generated := plugin.NewGeneratedFile(filename, "") @@ -54,6 +63,7 @@ func renderKotlinFile(plugin *protogen.Plugin, model JVMFileModel) error { "com.networknt.schema.InputFormat", "com.networknt.schema.JsonSchemaFactory", "com.networknt.schema.SpecVersion", + "com.google.protobuf.Descriptors", "com.google.protobuf.Message", "com.google.protobuf.util.JsonFormat", "io.modelcontextprotocol.kotlin.sdk.server.ClientConnection", @@ -90,7 +100,7 @@ func renderKotlinFile(plugin *protogen.Plugin, model JVMFileModel) error { } generated.P() - renderKotlinRuntime(generated) + renderKotlinRuntime(generated, descriptorAccessors) for serviceIdx, service := range model.Services { generated.P("interface ", service.HandlerName, " {") @@ -200,7 +210,7 @@ func (k kotlinRenderInfo) kotlinMessageTypeExpr(ref JVMTypeRef, imports map[stri return resolved.Expr, nil } -func renderKotlinRuntime(generated *protogen.GeneratedFile) { +func renderKotlinRuntime(generated *protogen.GeneratedFile, descriptorAccessors []javaDescriptorAccessor) { generated.P("private data class RegisteredTool(") generated.P(" val name: String,") generated.P(" val title: String?,") @@ -233,6 +243,39 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P() generated.P("private val serverRegistries = WeakHashMap()") generated.P("private val jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)") + generated.P("private val protoTypeRegistry = buildTypeRegistry()") + generated.P() + generated.P("private fun buildTypeRegistry(): JsonFormat.TypeRegistry {") + generated.P(" val builder = JsonFormat.TypeRegistry.newBuilder()") + generated.P(" val seenFiles = linkedSetOf()") + for _, accessor := range descriptorAccessors { + generated.P(" registerFileTypes(builder, seenFiles, ", accessor.Expr, ")") + } + generated.P(" return builder.build()") + generated.P("}") + generated.P() + generated.P("private fun registerFileTypes(") + generated.P(" builder: JsonFormat.TypeRegistry.Builder,") + generated.P(" seenFiles: MutableSet,") + generated.P(" file: Descriptors.FileDescriptor,") + generated.P(") {") + generated.P(" if (!seenFiles.add(file.fullName)) {") + generated.P(" return") + generated.P(" }") + generated.P(" for (descriptor in file.messageTypes) {") + generated.P(" registerMessageType(builder, descriptor)") + generated.P(" }") + generated.P("}") + generated.P() + generated.P("private fun registerMessageType(") + generated.P(" builder: JsonFormat.TypeRegistry.Builder,") + generated.P(" descriptor: Descriptors.Descriptor,") + generated.P(") {") + generated.P(" builder.add(descriptor)") + generated.P(" for (nested in descriptor.nestedTypes) {") + generated.P(" registerMessageType(builder, nested)") + generated.P(" }") + generated.P("}") generated.P() generated.P("private fun installMcpHandlers(server: Server): ServerToolRegistry {") generated.P(" val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() }") @@ -324,7 +367,8 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P(" val schema = loadSchema(rawSchemaJson)") generated.P(" val properties = schema[\"properties\"]?.jsonObject ?: buildJsonObject {}") generated.P(" val required = schema[\"required\"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()") - generated.P(" return ToolSchema(properties = properties, required = required)") + generated.P(" val defs = schema[\"\\$defs\"]?.jsonObject") + generated.P(" return ToolSchema(properties = properties, required = required, defs = defs)") generated.P("}") generated.P() generated.P("private fun validateJson(rawSchemaJson: String, payload: JsonElement) {") @@ -336,7 +380,7 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P() generated.P("private fun parseProtoJson(payload: String, builder: Message.Builder): Message {") generated.P(" try {") - generated.P(" JsonFormat.parser().merge(payload, builder)") + generated.P(" JsonFormat.parser().usingTypeRegistry(protoTypeRegistry).merge(payload, builder)") generated.P(" return builder.build()") generated.P(" } catch (error: Exception) {") generated.P(" invalidParams(\"\", error.message ?: error.toString())") @@ -344,7 +388,7 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile) { generated.P("}") generated.P() generated.P("private fun marshalProtoJson(message: Message): JsonObject {") - generated.P(" val jsonPayload = JsonFormat.printer().includingDefaultValueFields().print(message)") + generated.P(" val jsonPayload = JsonFormat.printer().usingTypeRegistry(protoTypeRegistry).includingDefaultValueFields().print(message)") generated.P(" return Json.parseToJsonElement(jsonPayload).jsonObject") generated.P("}") generated.P() diff --git a/internal/examplemcp/jvm_stdio_test.go b/internal/examplemcp/jvm_stdio_test.go new file mode 100644 index 0000000..1e24549 --- /dev/null +++ b/internal/examplemcp/jvm_stdio_test.go @@ -0,0 +1,167 @@ +package examplemcp_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var ( + ensureJVMExamplesInstalledOnce sync.Once + ensureJVMExamplesInstalledErr error +) + +func TestJavaServerOverStdio(t *testing.T) { + root := repoRoot(t) + ensureJVMExamplesInstalled(t, root) + + cmd := installedJVMServerCommand(root, "java-server") + runServerOverStdioContract(t, cmd) +} + +func TestKotlinServerOverStdio(t *testing.T) { + root := repoRoot(t) + ensureJVMExamplesInstalled(t, root) + + cmd := installedJVMServerCommand(root, "kotlin-server") + runServerOverStdioContract(t, cmd) +} + +func TestJavaServerRejectsInvalidInputOverStdio(t *testing.T) { + runJVMInvalidInputTest(t, "java-server") +} + +func TestKotlinServerRejectsInvalidInputOverStdio(t *testing.T) { + runJVMInvalidInputTest(t, "kotlin-server") +} + +func TestJavaServerRejectsInvalidOutputOverStdio(t *testing.T) { + runJVMInvalidOutputTest(t, "java-server") +} + +func TestKotlinServerRejectsInvalidOutputOverStdio(t *testing.T) { + runJVMInvalidOutputTest(t, "kotlin-server") +} + +func ensureJVMExamplesInstalled(t *testing.T, root string) { + t.Helper() + + ensureJVMExamplesInstalledOnce.Do(func() { + javaScript := filepath.Join(root, "examples/jvm/java-server/build/install/java-server/bin/java-server") + kotlinScript := filepath.Join(root, "examples/jvm/kotlin-server/build/install/kotlin-server/bin/kotlin-server") + if fileExists(javaScript) && fileExists(kotlinScript) { + return + } + + cmd := exec.Command( + "gradle", + "--no-daemon", + "-p", filepath.Join(root, "examples/jvm"), + ":java-server:installDist", + ":kotlin-server:installDist", + ) + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + ensureJVMExamplesInstalledErr = fmt.Errorf("install JVM example scripts: %w\n%s", err, output) + } + }) + + if ensureJVMExamplesInstalledErr != nil { + t.Fatal(ensureJVMExamplesInstalledErr) + } +} + +func installedJVMServerCommand(root, target string) *exec.Cmd { + script := filepath.Join(root, "examples/jvm", target, "build/install", target, "bin", target) + cmd := exec.Command(script) + cmd.Dir = root + return cmd +} + +func runJVMInvalidInputTest(t *testing.T, target string) { + t.Helper() + + root := repoRoot(t) + ensureJVMExamplesInstalled(t, root) + + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{ + Name: "protoc-gen-mcp-jvm-invalid-input-test-client", + Version: "v0.0.1", + }, nil) + + session, err := client.Connect(ctx, &mcp.CommandTransport{Command: installedJVMServerCommand(root, target)}, nil) + if err != nil { + t.Fatalf("client.Connect() over stdio failed: %v", err) + } + defer session.Close() + + _, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: "example_CreateReport", + Arguments: map[string]any{"count": 0}, + }) + if err == nil { + t.Fatal("CallTool(CreateReport) unexpectedly succeeded with invalid input") + } + + lower := strings.ToLower(err.Error()) + if !strings.Contains(lower, "invalid") { + t.Fatalf("CallTool(CreateReport) error = %v, want invalid-input failure", err) + } + normalized := strings.ReplaceAll(lower, "_", "") + if !strings.Contains(normalized, "examplecreatereport") { + t.Fatalf("CallTool(CreateReport) error = %v, want tool name in failure", err) + } +} + +func runJVMInvalidOutputTest(t *testing.T, target string) { + t.Helper() + + root := repoRoot(t) + ensureJVMExamplesInstalled(t, root) + + cmd := installedJVMServerCommand(root, target) + cmd.Env = append(os.Environ(), "PROTOC_GEN_MCP_JVM_INVALID_OUTPUT=create_report") + + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{ + Name: "protoc-gen-mcp-jvm-invalid-output-test-client", + Version: "v0.0.1", + }, nil) + + session, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil) + if err != nil { + t.Fatalf("client.Connect() over stdio failed: %v", err) + } + defer session.Close() + + _, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: "example_CreateReport", + Arguments: map[string]any{ + "city": "Paris", + "count": 2, + "details": map[string]any{"label": "today"}, + }, + }) + if err == nil { + t.Fatal("CallTool(CreateReport) unexpectedly succeeded with invalid output schema") + } + + lower := strings.ToLower(err.Error()) + if !strings.Contains(lower, "validate output") && !strings.Contains(lower, "output") { + t.Fatalf("CallTool(CreateReport) error = %v, want output validation failure", err) + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/testdata/golden/example_mcp.java.golden b/testdata/golden/example_mcp.java.golden index 4479aa2..10dceb8 100644 --- a/testdata/golden/example_mcp.java.golden +++ b/testdata/golden/example_mcp.java.golden @@ -3,6 +3,7 @@ package internal.testproto.example.v1; +import com.google.protobuf.Descriptors; import com.google.protobuf.Message; import com.google.protobuf.util.JsonFormat; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -23,9 +24,11 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.WeakHashMap; import java.util.function.Supplier; @@ -153,8 +156,37 @@ public final class ExampleMcp { }; private static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); private static final JsonSchemaValidator JSON_SCHEMA_VALIDATOR = McpJsonDefaults.getSchemaValidator(); + private static final JsonFormat.TypeRegistry PROTO_TYPE_REGISTRY = buildTypeRegistry(); private static final Map SERVER_REGISTRIES = Collections.synchronizedMap(new WeakHashMap<>()); + private static JsonFormat.TypeRegistry buildTypeRegistry() { + JsonFormat.TypeRegistry.Builder builder = JsonFormat.TypeRegistry.newBuilder(); + Set seenFiles = new HashSet<>(); + registerFileTypes(builder, seenFiles, Example.getDescriptor()); + return builder.build(); + } + + private static void registerFileTypes( + JsonFormat.TypeRegistry.Builder builder, + Set seenFiles, + Descriptors.FileDescriptor file) { + if (!seenFiles.add(file.getFullName())) { + return; + } + for (Descriptors.Descriptor descriptor : file.getMessageTypes()) { + registerMessageType(builder, descriptor); + } + } + + private static void registerMessageType( + JsonFormat.TypeRegistry.Builder builder, + Descriptors.Descriptor descriptor) { + builder.add(descriptor); + for (Descriptors.Descriptor nested : descriptor.getNestedTypes()) { + registerMessageType(builder, nested); + } + } + @FunctionalInterface private interface ToolInvoker { Message handle(McpAsyncServerExchange exchange, Message request) throws Exception; @@ -383,7 +415,7 @@ public final class ExampleMcp { private static Message parseProtoJson(String argumentsJson, Message.Builder builder) { try { - JsonFormat.parser().merge(argumentsJson, builder); + JsonFormat.parser().usingTypeRegistry(PROTO_TYPE_REGISTRY).merge(argumentsJson, builder); return builder.build(); } catch (Exception error) { @@ -394,6 +426,7 @@ public final class ExampleMcp { private static String marshalProtoJson(Message responseMessage) { try { return JsonFormat.printer() + .usingTypeRegistry(PROTO_TYPE_REGISTRY) .alwaysPrintFieldsWithNoPresence() .omittingInsignificantWhitespace() .print(responseMessage); diff --git a/testdata/golden/example_mcp.kt.golden b/testdata/golden/example_mcp.kt.golden index f76f4eb..75259cc 100644 --- a/testdata/golden/example_mcp.kt.golden +++ b/testdata/golden/example_mcp.kt.golden @@ -3,6 +3,7 @@ package internal.testproto.example.v1 +import com.google.protobuf.Descriptors import com.google.protobuf.Message import com.google.protobuf.util.JsonFormat import com.networknt.schema.InputFormat @@ -65,6 +66,37 @@ private class ServerToolRegistry { private val serverRegistries = WeakHashMap() private val jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) +private val protoTypeRegistry = buildTypeRegistry() + +private fun buildTypeRegistry(): JsonFormat.TypeRegistry { + val builder = JsonFormat.TypeRegistry.newBuilder() + val seenFiles = linkedSetOf() + registerFileTypes(builder, seenFiles, Example.getDescriptor()) + return builder.build() +} + +private fun registerFileTypes( + builder: JsonFormat.TypeRegistry.Builder, + seenFiles: MutableSet, + file: Descriptors.FileDescriptor, +) { + if (!seenFiles.add(file.fullName)) { + return + } + for (descriptor in file.messageTypes) { + registerMessageType(builder, descriptor) + } +} + +private fun registerMessageType( + builder: JsonFormat.TypeRegistry.Builder, + descriptor: Descriptors.Descriptor, +) { + builder.add(descriptor) + for (nested in descriptor.nestedTypes) { + registerMessageType(builder, nested) + } +} private fun installMcpHandlers(server: Server): ServerToolRegistry { val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() } @@ -156,7 +188,8 @@ private fun projectToolSchema(rawSchemaJson: String): ToolSchema { val schema = loadSchema(rawSchemaJson) val properties = schema["properties"]?.jsonObject ?: buildJsonObject {} val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList() - return ToolSchema(properties = properties, required = required) + val defs = schema["\$defs"]?.jsonObject + return ToolSchema(properties = properties, required = required, defs = defs) } private fun validateJson(rawSchemaJson: String, payload: JsonElement) { @@ -168,7 +201,7 @@ private fun validateJson(rawSchemaJson: String, payload: JsonElement) { private fun parseProtoJson(payload: String, builder: Message.Builder): Message { try { - JsonFormat.parser().merge(payload, builder) + JsonFormat.parser().usingTypeRegistry(protoTypeRegistry).merge(payload, builder) return builder.build() } catch (error: Exception) { invalidParams("", error.message ?: error.toString()) @@ -176,7 +209,7 @@ private fun parseProtoJson(payload: String, builder: Message.Builder): Message { } private fun marshalProtoJson(message: Message): JsonObject { - val jsonPayload = JsonFormat.printer().includingDefaultValueFields().print(message) + val jsonPayload = JsonFormat.printer().usingTypeRegistry(protoTypeRegistry).includingDefaultValueFields().print(message) return Json.parseToJsonElement(jsonPayload).jsonObject } From 4e76affb2d1e0732cc6dc2ddc36c09a5d711a7d8 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Fri, 1 May 2026 23:20:03 +0300 Subject: [PATCH 21/74] Add JVM standalone examples and Python execution metadata --- .github/workflows/tests.yml | 5 - .gitignore | 4 + AGENTS.md | 40 ++- README.md | 59 ++++- examples/1_helloworld/proto/helloworld_mcp.py | 8 + examples/2_weather_api/proto/weather_mcp.py | 8 + .../3_file_manager/proto/filemanager_mcp.py | 9 + examples/4_crm_system/proto/crm_mcp.py | 9 + .../5_python_standalone/proto/notebook_mcp.py | 10 + examples/6_java_standalone/.gitignore | 4 + examples/6_java_standalone/Makefile | 19 ++ examples/6_java_standalone/README.md | 51 ++++ examples/6_java_standalone/build.gradle.kts | 53 ++++ examples/6_java_standalone/easyp.lock | 1 + examples/6_java_standalone/easyp.yaml | 35 +++ examples/6_java_standalone/proto/todo.proto | 115 +++++++++ .../6_java_standalone/settings.gradle.kts | 17 ++ .../com/example/todo/java/TodoJavaServer.java | 73 ++++++ examples/7_kotlin_standalone/.gitignore | 4 + examples/7_kotlin_standalone/Makefile | 19 ++ examples/7_kotlin_standalone/README.md | 51 ++++ examples/7_kotlin_standalone/build.gradle.kts | 68 +++++ examples/7_kotlin_standalone/easyp.lock | 1 + examples/7_kotlin_standalone/easyp.yaml | 37 +++ examples/7_kotlin_standalone/proto/todo.proto | 115 +++++++++ .../7_kotlin_standalone/settings.gradle.kts | 17 ++ .../example/todo/kotlin/TodoKotlinServer.kt | 90 +++++++ examples/Makefile | 11 +- examples/README.md | 42 ++- examples/python_stdio_test.go | 54 +--- go.mod | 4 +- go.sum | 16 +- internal/codegen/python_contract_test.go | 56 ++++ internal/codegen/python_mapper_test.go | 12 +- internal/codegen/render_python.go | 12 + internal/codegen/render_python_runtime.go | 7 + .../codegen/{python_request.go => request.go} | 23 +- ...python_request_test.go => request_test.go} | 80 ++++-- internal/examplemcp/stdio_test.go | 27 +- internal/pythontest/env.go | 240 ++++++++++++++++++ internal/testproto/example/v1/example_mcp.py | 12 + mcp/options/v1/options_mcp.py | 7 + testdata/golden/example_mcp.py.golden | 12 + 43 files changed, 1387 insertions(+), 150 deletions(-) create mode 100644 examples/6_java_standalone/.gitignore create mode 100644 examples/6_java_standalone/Makefile create mode 100644 examples/6_java_standalone/README.md create mode 100644 examples/6_java_standalone/build.gradle.kts create mode 100644 examples/6_java_standalone/easyp.lock create mode 100644 examples/6_java_standalone/easyp.yaml create mode 100644 examples/6_java_standalone/proto/todo.proto create mode 100644 examples/6_java_standalone/settings.gradle.kts create mode 100644 examples/6_java_standalone/src/main/java/com/example/todo/java/TodoJavaServer.java create mode 100644 examples/7_kotlin_standalone/.gitignore create mode 100644 examples/7_kotlin_standalone/Makefile create mode 100644 examples/7_kotlin_standalone/README.md create mode 100644 examples/7_kotlin_standalone/build.gradle.kts create mode 100644 examples/7_kotlin_standalone/easyp.lock create mode 100644 examples/7_kotlin_standalone/easyp.yaml create mode 100644 examples/7_kotlin_standalone/proto/todo.proto create mode 100644 examples/7_kotlin_standalone/settings.gradle.kts create mode 100644 examples/7_kotlin_standalone/src/main/kotlin/com/example/todo/kotlin/TodoKotlinServer.kt rename internal/codegen/{python_request.go => request.go} (62%) rename internal/codegen/{python_request_test.go => request_test.go} (50%) create mode 100644 internal/pythontest/env.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89002de..34e0833 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,11 +36,6 @@ jobs: with: gradle-version: "9.2.1" - - name: Install Python runtime dependencies - run: | - python -m pip install --upgrade pip - python -m pip install "mcp>=1.27,<2" "protobuf>=6,<7" "jsonschema>=4,<5" - - name: Install easyp run: go install github.com/easyp-tech/easyp/cmd/easyp@v0.15.2-rc1 diff --git a/.gitignore b/.gitignore index eeda822..e124eed 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,8 @@ examples/3_file_manager/file-server examples/4_crm_system/crm-server examples/jvm/.gradle/ examples/jvm/*/build/ +examples/6_java_standalone/.gradle/ +examples/6_java_standalone/build/ +examples/7_kotlin_standalone/.gradle/ +examples/7_kotlin_standalone/build/ .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 5f69ed9..937445a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,9 @@ explicitly revised. - Go 1.24+ - Python 3.10+ for generated-runtime verification and example servers +- Python runtime tests bootstrap an isolated virtualenv through + `internal/pythontest`; they must not depend on globally installed Python + packages such as `protobuf` - Gradle 9.2+ and JDK 17+ for JVM compile-gate verification - `easyp v0.15.2-rc1` for repository linting and code generation workflows - `google.golang.org/protobuf` for code generation, reflection, and ProtoJSON @@ -35,8 +38,8 @@ explicitly revised. - `.goreleaser.yaml`: release packaging for the plugin binary - `examples`: standalone Go/Python/JVM integration projects; example directories use numeric underscore prefixes such as `1_helloworld`, - `4_crm_system`, and `5_python_standalone`, plus the dedicated JVM workspace - `examples/jvm` + `4_crm_system`, `5_python_standalone`, `6_java_standalone`, and + `7_kotlin_standalone`, plus the dedicated JVM workspace `examples/jvm` - `examples/easyp.lock`: pinned Easyp dependency lock for standalone examples - `examples/mcp`: generated Python `mcp.options.*` protobuf modules for standalone examples; generated from the GitHub dependency declared in @@ -44,6 +47,13 @@ explicitly revised. - `examples/5_python_standalone`: Python-only user-style example with its own `pyproject.toml`, `easyp.yaml`, generated `proto`/`mcp` packages, and stdio server +- `examples/6_java_standalone`: Java user-style standalone project with its + own Gradle build, `easyp.yaml`, protobuf contract, generated Java protobuf + classes, generated `lang=java` MCP sidecar, and handwritten stdio server +- `examples/7_kotlin_standalone`: Kotlin user-style standalone project with + its own Gradle build, `easyp.yaml`, protobuf contract, generated Java/Kotlin + protobuf classes, generated `lang=kotlin` MCP sidecar, and handwritten stdio + server - `examples/jvm`: isolated Gradle Kotlin DSL workspace that compiles generated Java/Kotlin JVM sidecars against Maven `protoc`, official MCP SDK artifacts, and the local `cmd/protoc-gen-mcp` binary @@ -73,6 +83,8 @@ explicitly revised. - `internal/codegen/kotlin_contract_test.go`: Kotlin public API, SDK wiring, schema-path, and JVM import contract tests - `internal/examplemcp`: reusable example MCP server wiring and stdio smoke test +- `internal/pythontest`: hermetic Python test runtime bootstrap used by Go + tests that execute generated Python code - `internal/schema`: protobuf descriptor to JSON Schema conversion - `internal/testproto`: protobuf fixtures and generated code used in repository tests - `internal/testproto/example/v1/__init__.py`: generated Python package marker @@ -177,9 +189,9 @@ explicitly revised. - single-source custom generator option handling through `protogen.Options.ParamFunc`, with fail-fast rejection of unknown `protoc-gen-mcp` params - - Python-mode request preparation synthesizes internal `go_package` metadata - for `.proto` files that omit it, so Python-only users do not need - Go-specific proto options just to run `lang=python` + - non-Go request preparation synthesizes internal `go_package` metadata for + `.proto` files that omit it, so Python/JVM users do not need Go-specific + proto options just to run `lang=python`, `lang=java`, or `lang=kotlin` - generated self-contained Python `*_mcp.py` bindings for `lang=python,python_runtime=google.protobuf`, including handler protocols, dataclasses, `UNSET`, explicit `oneof` wrapper variants, schema JSON @@ -224,9 +236,9 @@ explicitly revised. current-file types actually referenced by other generated files, so cross-file imports work without forcing unrelated hidden-only types into the public API surface - - dedicated `examples/` directory featuring 5 standalone integration - projects spanning quickstarts to complex CRM mocks and a pure Python - user-style project + - dedicated `examples/` directory featuring 7 standalone integration + projects spanning quickstarts to complex CRM mocks and pure Python, Java, + and Kotlin user-style projects - support for `oneof` explicit requiredness through `mcp.options.v1.oneof` options - strict schema generation correctly differentiating zero-values (`0`, `0.0`, `""`) using pointer constraints - runtime registration and JSON Schema validation in `mcpruntime` @@ -273,6 +285,10 @@ explicitly revised. `ExampleMcp.registerExampleAPITools(...)` and `registerExampleAPITools(server, impl, namespace = "example")` instead of SDK `addTool` APIs +- standalone Java and Kotlin user-style projects under + `examples/6_java_standalone` and `examples/7_kotlin_standalone`, each with + its own `easyp.yaml`, lockfile, Gradle build, handwritten stdio server, and + generated-source cleanup for Google protobuf classes supplied by Maven - repository docs now route JVM users through `examples/jvm/README.md`, and rollout messaging states that releases publish the `protoc-gen-mcp binary` while downstream JVM users compile generated sources against the official SDK @@ -299,6 +315,10 @@ explicitly revised. for the Phase 04 JVM compile gate - `gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist` for installable JVM example scripts +- `cd examples/6_java_standalone && make lint && make clean build` + for the standalone Java user-project generation and compile flow +- `cd examples/7_kotlin_standalone && make lint && make clean build` + for the standalone Kotlin user-project generation and compile flow - `go test ./...` - stdio smoke tests via `internal/examplemcp/stdio_test.go` - `go test ./internal/examplemcp -run 'TestJava.*OverStdio' -count=1` @@ -366,6 +386,10 @@ explicitly revised. - `gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist` - Run Python-only standalone example: - `cd examples/5_python_standalone && make setup && make run` +- Build standalone Java example: + - `cd examples/6_java_standalone && make build` +- Build standalone Kotlin example: + - `cd examples/7_kotlin_standalone && make build` - Build plugin: `go build ./cmd/protoc-gen-mcp` - Validate GoReleaser config: `goreleaser check` - Build example MCP server binary: diff --git a/README.md b/README.md index 66017ac..7510473 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,10 @@ not publish separate Java or Kotlin runtime artifacts. ## JVM Support JVM support is implemented and CI-verified through the runnable -[`examples/jvm`](examples/jvm/README.md) workspace. +[`examples/jvm`](examples/jvm/README.md) workspace. User-owned standalone +project layouts are shown in +[`examples/6_java_standalone`](examples/6_java_standalone/) and +[`examples/7_kotlin_standalone`](examples/7_kotlin_standalone/). - JVM prerequisites for the in-repo walkthrough are Go 1.24+, JDK 17+, and Gradle 9.2+. @@ -72,10 +75,14 @@ JVM support is implemented and CI-verified through the runnable - The canonical runnable JVM verification path uses `installDist` plus the installed scripts under `examples/jvm/*/build/install/.../bin/*`, matching `internal/examplemcp/jvm_stdio_test.go` and CI. +- Standalone JVM protos can be Java/Kotlin-native and omit `go_package`; the + generator synthesizes internal protogen metadata for `lang=java` and + `lang=kotlin`. -Use the root README for the language matrix and repository workflow, then go to -[examples/jvm/README.md](examples/jvm/README.md) for the exact Java/Kotlin -walkthrough. +Use the root README for the language matrix and repository workflow, inspect +the standalone Java/Kotlin directories for user-project shape, and use +[examples/jvm/README.md](examples/jvm/README.md) for the cross-language JVM +verification workspace. ## Test MCP Server @@ -96,6 +103,40 @@ The example server currently exposes: - `example_DescribeAdvancedShapes` - `example_DescribeScalarShapes` +## Testing With MCP Inspector + +Use the official +[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for +interactive stdio checks while developing a server. The Inspector runs through +`npx` and starts the MCP server command you pass after the Inspector package. + +For the Go example server: + +```bash +npx -y @modelcontextprotocol/inspector go run ./cmd/example-mcp-server +``` + +For the Python example server: + +```bash +npx -y @modelcontextprotocol/inspector python ./cmd/example-python-mcp-server/main.py +``` + +For installed JVM examples, build the scripts first and then point the +Inspector at the installed application: + +```bash +gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist +npx -y @modelcontextprotocol/inspector ./examples/jvm/java-server/build/install/java-server/bin/java-server +npx -y @modelcontextprotocol/inspector ./examples/jvm/kotlin-server/build/install/kotlin-server/bin/kotlin-server +``` + +When the Inspector UI opens, connect to the server, open the Tools tab, run +List Tools, then call a generated tool such as `example_Health` or +`example_CreateReport`. This is the fastest manual check that generated tool +names, schemas, annotations, ProtoJSON inputs, structured outputs, and server +stdio wiring are visible to an MCP client. + ## Examples We provide several standalone, runnable examples demonstrating generated MCP @@ -107,6 +148,8 @@ official Go, Python, Kotlin, and Java SDKs. Check out the - [3_file_manager](examples/3_file_manager/) - Destructive tools and schema-based string parameter constraints. - [4_crm_system](examples/4_crm_system/) - A full mock system with FieldMask partial updates, custom icons mapping, schemas nested types, and advanced array filters. - [5_python_standalone](examples/5_python_standalone/) - A Python-only user-style project with its own `pyproject.toml`, `easyp.yaml`, generated bindings, and stdio server. +- [6_java_standalone](examples/6_java_standalone/) - A Java user-style project with its own Gradle build, `easyp.yaml`, protobuf contract, and generated MCP sidecar. +- [7_kotlin_standalone](examples/7_kotlin_standalone/) - A Kotlin user-style project with its own Gradle build, `easyp.yaml`, protobuf contract, and generated MCP sidecar. - [jvm](examples/jvm/README.md) - A Java/Kotlin official SDK workspace with Gradle-managed protobuf generation, `lang=java` / `lang=kotlin` sidecars, `installDist` scripts, and stdio verification. @@ -206,9 +249,11 @@ For JVM consumers, the language selectors are `lang=java` and `lang=kotlin`. The Java path generates a Java sidecar alongside protobuf Java output. The Kotlin path must follow the same rule as the in-repo Gradle example: Java protobuf output, Kotlin protobuf output, and the `lang=kotlin` MCP sidecar are -all part of the working build graph. See -[examples/jvm/README.md](examples/jvm/README.md) for the runnable, tested -workspace. +all part of the working build graph. User-authored JVM protos do not need a +Go `go_package` option just to satisfy `protoc-gen-mcp`. See +[examples/6_java_standalone](examples/6_java_standalone/), +[examples/7_kotlin_standalone](examples/7_kotlin_standalone/), and +[examples/jvm/README.md](examples/jvm/README.md) for runnable layouts. For Python-only projects, omit the Go plugins and keep only the standard `python` plugin plus `protoc-gen-mcp` with `lang=python`. User-authored Python diff --git a/examples/1_helloworld/proto/helloworld_mcp.py b/examples/1_helloworld/proto/helloworld_mcp.py index 6c1998f..c517e32 100644 --- a/examples/1_helloworld/proto/helloworld_mcp.py +++ b/examples/1_helloworld/proto/helloworld_mcp.py @@ -74,6 +74,7 @@ class _RegisteredTool: handler: Any annotations: dict[str, Any] | None icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None class _ServerToolRegistry: def __init__(self, server: mcp.server.lowlevel.Server) -> None: @@ -215,6 +216,11 @@ def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | return None return mcp.types.ToolAnnotations(**raw) +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + def _tool_error_result(message: str) -> mcp.types.CallToolResult: return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=message)], @@ -238,6 +244,7 @@ def _build_tool(tool: _RegisteredTool) -> Any: outputSchema=_load_schema(tool.output_schema_json), annotations=_tool_annotations(tool.annotations), icons=tool.icons, + execution=_tool_execution(tool.execution), ) async def _maybe_await(result: Any) -> Any: @@ -436,6 +443,7 @@ def register_greeter_api_tools(server: mcp.server.lowlevel.Server, impl: Greeter handler=impl.say_hello, annotations=None, icons=None, + execution=None, )) GREETER_API_SAY_HELLO_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"description\":\"The name of the person to greet.\",\"examples\":[\"Alice\"]}},\"examples\":[{\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}" diff --git a/examples/2_weather_api/proto/weather_mcp.py b/examples/2_weather_api/proto/weather_mcp.py index 0d8676d..3f9006a 100644 --- a/examples/2_weather_api/proto/weather_mcp.py +++ b/examples/2_weather_api/proto/weather_mcp.py @@ -92,6 +92,7 @@ class _RegisteredTool: handler: Any annotations: dict[str, Any] | None icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None class _ServerToolRegistry: def __init__(self, server: mcp.server.lowlevel.Server) -> None: @@ -233,6 +234,11 @@ def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | return None return mcp.types.ToolAnnotations(**raw) +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + def _tool_error_result(message: str) -> mcp.types.CallToolResult: return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=message)], @@ -256,6 +262,7 @@ def _build_tool(tool: _RegisteredTool) -> Any: outputSchema=_load_schema(tool.output_schema_json), annotations=_tool_annotations(tool.annotations), icons=tool.icons, + execution=_tool_execution(tool.execution), ) async def _maybe_await(result: Any) -> Any: @@ -481,6 +488,7 @@ def register_weather_api_tools(server: mcp.server.lowlevel.Server, impl: Weather handler=impl.get_current_weather, annotations={"readOnlyHint": True, "openWorldHint": True}, icons=None, + execution=None, )) WEATHER_API_GET_CURRENT_WEATHER_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":[\"string\",\"null\"],\"description\":\"Name of the city (e.g., 'London', 'Tokyo').\",\"examples\":[\"Paris\"],\"minLength\":2},\"coordinates\":{\"type\":[\"object\",\"null\"],\"properties\":{\"latitude\":{\"description\":\"Latitude in decimal degrees.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"longitude\":{\"description\":\"Longitude in decimal degrees.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]}},\"examples\":[{\"latitude\":1.25,\"longitude\":1.25}],\"required\":[\"latitude\",\"longitude\"],\"additionalProperties\":false}},\"examples\":[{\"city\":\"example\"}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"city\"],\"not\":{\"properties\":{\"city\":{\"type\":\"null\"}},\"required\":[\"city\"]}},{\"required\":[\"coordinates\"],\"not\":{\"properties\":{\"coordinates\":{\"type\":\"null\"}},\"required\":[\"coordinates\"]}}]}},{\"allOf\":[{\"required\":[\"city\"],\"not\":{\"properties\":{\"city\":{\"type\":\"null\"}},\"required\":[\"city\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"coordinates\"],\"not\":{\"properties\":{\"coordinates\":{\"type\":\"null\"}},\"required\":[\"coordinates\"]}}]}}]},{\"allOf\":[{\"required\":[\"coordinates\"],\"not\":{\"properties\":{\"coordinates\":{\"type\":\"null\"}},\"required\":[\"coordinates\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"city\"],\"not\":{\"properties\":{\"city\":{\"type\":\"null\"}},\"required\":[\"city\"]}}]}}]}]}]}" diff --git a/examples/3_file_manager/proto/filemanager_mcp.py b/examples/3_file_manager/proto/filemanager_mcp.py index 0223fe7..f054022 100644 --- a/examples/3_file_manager/proto/filemanager_mcp.py +++ b/examples/3_file_manager/proto/filemanager_mcp.py @@ -82,6 +82,7 @@ class _RegisteredTool: handler: Any annotations: dict[str, Any] | None icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None class _ServerToolRegistry: def __init__(self, server: mcp.server.lowlevel.Server) -> None: @@ -223,6 +224,11 @@ def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | return None return mcp.types.ToolAnnotations(**raw) +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + def _tool_error_result(message: str) -> mcp.types.CallToolResult: return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=message)], @@ -246,6 +252,7 @@ def _build_tool(tool: _RegisteredTool) -> Any: outputSchema=_load_schema(tool.output_schema_json), annotations=_tool_annotations(tool.annotations), icons=tool.icons, + execution=_tool_execution(tool.execution), ) async def _maybe_await(result: Any) -> Any: @@ -466,6 +473,7 @@ def register_file_manager_api_tools(server: mcp.server.lowlevel.Server, impl: Fi handler=impl.read_file, annotations={"readOnlyHint": True, "idempotentHint": True}, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "DeleteFile"), @@ -480,6 +488,7 @@ def register_file_manager_api_tools(server: mcp.server.lowlevel.Server, impl: Fi handler=impl.delete_file, annotations={"destructiveHint": True}, icons=None, + execution=None, )) FILE_MANAGER_API_READ_FILE_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"filename\":{\"type\":\"string\",\"description\":\"The name of the file to read. Must not contain paths or directories.\",\"examples\":[\"example\"],\"minLength\":1,\"pattern\":\"^[a-zA-Z0-9_\\\\-\\\\.]+$\"}},\"examples\":[{\"filename\":\"example\"}],\"required\":[\"filename\"],\"additionalProperties\":false}" diff --git a/examples/4_crm_system/proto/crm_mcp.py b/examples/4_crm_system/proto/crm_mcp.py index caffc0d..510247c 100644 --- a/examples/4_crm_system/proto/crm_mcp.py +++ b/examples/4_crm_system/proto/crm_mcp.py @@ -91,6 +91,7 @@ class _RegisteredTool: handler: Any annotations: dict[str, Any] | None icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None class _ServerToolRegistry: def __init__(self, server: mcp.server.lowlevel.Server) -> None: @@ -232,6 +233,11 @@ def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | return None return mcp.types.ToolAnnotations(**raw) +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + def _tool_error_result(message: str) -> mcp.types.CallToolResult: return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=message)], @@ -255,6 +261,7 @@ def _build_tool(tool: _RegisteredTool) -> Any: outputSchema=_load_schema(tool.output_schema_json), annotations=_tool_annotations(tool.annotations), icons=tool.icons, + execution=_tool_execution(tool.execution), ) async def _maybe_await(result: Any) -> Any: @@ -495,6 +502,7 @@ def register_users_api_tools(server: mcp.server.lowlevel.Server, impl: UsersAPIT handler=impl.list_users, annotations={"readOnlyHint": True}, icons=[{"src": "https://example.com/crm-icon.png", "mimeType": "image/png", "sizes": [], "theme": ""}], + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "UpdateUser"), @@ -509,6 +517,7 @@ def register_users_api_tools(server: mcp.server.lowlevel.Server, impl: UsersAPIT handler=impl.update_user, annotations=None, icons=[{"src": "https://example.com/edit-icon.svg", "mimeType": "image/svg+xml", "sizes": [], "theme": ""}], + execution=None, )) USERS_API_LIST_USERS_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"limit\":{\"type\":\"integer\",\"description\":\"Maximum number of users to return.\",\"examples\":[-1],\"minimum\":1,\"maximum\":100},\"requiredTags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Filter users who must have ALL these tags.\",\"examples\":[\"example\"]},\"description\":\"Filter users who must have ALL these tags.\",\"examples\":[[\"example\"]]}},\"examples\":[{\"limit\":-1,\"requiredTags\":[\"example\"]}],\"required\":[\"limit\"],\"additionalProperties\":false}" diff --git a/examples/5_python_standalone/proto/notebook_mcp.py b/examples/5_python_standalone/proto/notebook_mcp.py index 2a4f8ab..00e3f8f 100644 --- a/examples/5_python_standalone/proto/notebook_mcp.py +++ b/examples/5_python_standalone/proto/notebook_mcp.py @@ -105,6 +105,7 @@ class _RegisteredTool: handler: Any annotations: dict[str, Any] | None icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None class _ServerToolRegistry: def __init__(self, server: mcp.server.lowlevel.Server) -> None: @@ -246,6 +247,11 @@ def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | return None return mcp.types.ToolAnnotations(**raw) +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + def _tool_error_result(message: str) -> mcp.types.CallToolResult: return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=message)], @@ -269,6 +275,7 @@ def _build_tool(tool: _RegisteredTool) -> Any: outputSchema=_load_schema(tool.output_schema_json), annotations=_tool_annotations(tool.annotations), icons=tool.icons, + execution=_tool_execution(tool.execution), ) async def _maybe_await(result: Any) -> Any: @@ -545,6 +552,7 @@ def register_notebook_api_tools(server: mcp.server.lowlevel.Server, impl: Notebo handler=impl.create_note, annotations={"destructiveHint": False, "openWorldHint": False}, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "SearchNotes"), @@ -559,6 +567,7 @@ def register_notebook_api_tools(server: mcp.server.lowlevel.Server, impl: Notebo handler=impl.search_notes, annotations={"readOnlyHint": True, "openWorldHint": False}, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "Health"), @@ -573,6 +582,7 @@ def register_notebook_api_tools(server: mcp.server.lowlevel.Server, impl: Notebo handler=impl.health, annotations={"readOnlyHint": True, "openWorldHint": False}, icons=None, + execution=None, )) NOTEBOOK_API_CREATE_NOTE_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"body\":{\"type\":\"string\",\"description\":\"Note body in plain text.\",\"examples\":[\"Verify that generated Python MCP bindings are pleasant to use.\"],\"minLength\":1,\"maxLength\":2000},\"dueDate\":{\"type\":[\"string\",\"null\"],\"description\":\"Optional due date in ISO 8601 date format.\",\"examples\":[\"2026-04-30\"],\"format\":\"date\"},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"python\",\"mcp\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable note title.\",\"examples\":[\"Ship Python support\"],\"minLength\":1,\"maxLength\":80}},\"examples\":[{\"body\":\"example\",\"dueDate\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"title\",\"body\"],\"additionalProperties\":false}" diff --git a/examples/6_java_standalone/.gitignore b/examples/6_java_standalone/.gitignore new file mode 100644 index 0000000..0fbaf99 --- /dev/null +++ b/examples/6_java_standalone/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +src/generated/ +google/ diff --git a/examples/6_java_standalone/Makefile b/examples/6_java_standalone/Makefile new file mode 100644 index 0000000..27afa88 --- /dev/null +++ b/examples/6_java_standalone/Makefile @@ -0,0 +1,19 @@ +.PHONY: build clean generate lint run + +generate: + easyp --cfg easyp.yaml mod download + easyp --cfg easyp.yaml generate -p proto -r . + rm -rf google src/generated/main/java/com/google + +lint: + easyp --cfg easyp.yaml mod download + easyp --cfg easyp.yaml lint -p proto -r . + +build: + gradle --no-daemon build + +run: + gradle --no-daemon run + +clean: + rm -rf .gradle build src/generated google diff --git a/examples/6_java_standalone/README.md b/examples/6_java_standalone/README.md new file mode 100644 index 0000000..73ca25e --- /dev/null +++ b/examples/6_java_standalone/README.md @@ -0,0 +1,51 @@ +# Standalone Java MCP Server + +This example is structured like a user-owned Java project. It has its own +`easyp.yaml`, Gradle build, protobuf contract, generated source directory, and +stdio MCP server. + +## Generate + +```bash +make generate +``` + +`easyp.yaml` does two jobs: + +- resolves `mcp/options/v1/options.proto` through `deps` +- runs Java protobuf generation plus `protoc-gen-mcp` with `lang=java` + +The proto is Java-native and intentionally does not declare `go_package`. +`protoc-gen-mcp` synthesizes the internal metadata that `protogen` needs for +`lang=java`. + +`with_imports: true` is used so the local `mcp.options.v1` Java extension +class is generated from the Easyp dependency. The build removes generated +`com.google.protobuf` sources afterward and uses the protobuf Java jar for +Google runtime classes. + +The local repository version uses: + +```yaml +command: ["go", "run", "../../cmd/protoc-gen-mcp"] +``` + +In an external project, replace it with the released plugin binary or module +entrypoint you want to pin. + +## Build And Run + +```bash +make build +make run +``` + +The server registers generated tools through: + +```java +TodoMcp.registerTodoAPITools(transportProvider, new Handler(), "todo"); +``` + +The generated source lives under `src/generated/main/java` after generation. +The handwritten server lives under `src/main/java` and implements the generated +`TodoMcp.TodoAPIToolHandler` interface. diff --git a/examples/6_java_standalone/build.gradle.kts b/examples/6_java_standalone/build.gradle.kts new file mode 100644 index 0000000..978af13 --- /dev/null +++ b/examples/6_java_standalone/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + application + java +} + +dependencies { + implementation("io.modelcontextprotocol.sdk:mcp:1.1.1") + implementation("com.google.protobuf:protobuf-java:4.34.1") + implementation("com.google.protobuf:protobuf-java-util:4.34.1") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +application { + mainClass.set("com.example.todo.java.TodoJavaServer") +} + +sourceSets { + named("main") { + java { + srcDir("src/generated/main/java") + } + } +} + +tasks.register("easypModDownload") { + group = "build" + description = "Download protobuf dependencies declared in easyp.yaml." + commandLine("easyp", "--cfg", "easyp.yaml", "mod", "download") +} + +tasks.register("generateProto") { + group = "build" + description = "Generate protobuf Java classes and MCP Java sidecars." + dependsOn("easypModDownload") + commandLine("easyp", "--cfg", "easyp.yaml", "generate", "-p", "proto", "-r", ".") + doLast { + delete("google", "src/generated/main/java/com/google") + } +} + +tasks.named("compileJava") { + dependsOn("generateProto") +} + +tasks.named("clean") { + doLast { + delete("src/generated", "google") + } +} diff --git a/examples/6_java_standalone/easyp.lock b/examples/6_java_standalone/easyp.lock new file mode 100644 index 0000000..c30dd0d --- /dev/null +++ b/examples/6_java_standalone/easyp.lock @@ -0,0 +1 @@ +github.com/easyp-tech/protoc-gen-mcp v0.4.0^{} h1:89szb16JOQw4+3VcouZEvsW8Kh6XMZi8kntiD8OOvcI= diff --git a/examples/6_java_standalone/easyp.yaml b/examples/6_java_standalone/easyp.yaml new file mode 100644 index 0000000..34fdb61 --- /dev/null +++ b/examples/6_java_standalone/easyp.yaml @@ -0,0 +1,35 @@ +deps: + - github.com/easyp-tech/protoc-gen-mcp + +lint: + use: + - PACKAGE_DEFINED + - PACKAGE_LOWER_SNAKE_CASE + - PACKAGE_VERSION_SUFFIX + - FILE_LOWER_SNAKE_CASE + - MESSAGE_PASCAL_CASE + - FIELD_LOWER_SNAKE_CASE + - RPC_PASCAL_CASE + - SERVICE_PASCAL_CASE + - SERVICE_SUFFIX + - RPC_REQUEST_RESPONSE_UNIQUE + - RPC_REQUEST_STANDARD_NAME + - RPC_RESPONSE_STANDARD_NAME + - RPC_NO_CLIENT_STREAMING + - RPC_NO_SERVER_STREAMING + service_suffix: API + +generate: + inputs: + - directory: + path: proto + root: "." + plugins: + - name: java + with_imports: true + out: src/generated/main/java + - command: ["go", "run", "../../cmd/protoc-gen-mcp"] + out: src/generated/main/java + opts: + paths: source_relative + lang: java diff --git a/examples/6_java_standalone/proto/todo.proto b/examples/6_java_standalone/proto/todo.proto new file mode 100644 index 0000000..bf0d1b3 --- /dev/null +++ b/examples/6_java_standalone/proto/todo.proto @@ -0,0 +1,115 @@ +syntax = "proto3"; + +package standalone.todo.v1; + +import "mcp/options/v1/options.proto"; + +option java_package = "com.example.todo.v1"; +option java_outer_classname = "TodoProto"; + +service TodoAPI { + option (mcp.options.v1.service) = { + namespace: "todo" + description: "Todo tools exposed by a standalone Java MCP server." + }; + + rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse) { + option (mcp.options.v1.method) = { + name: "CreateTask" + title: "Create Task" + description: "Create a task in the in-memory todo list." + annotations: { + destructive_hint: false + idempotent_hint: false + open_world_hint: false + } + }; + } + + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse) { + option (mcp.options.v1.method) = { + name: "ListTasks" + title: "List Tasks" + description: "List current tasks from the in-memory todo list." + annotations: { + read_only_hint: true + destructive_hint: false + idempotent_hint: true + open_world_hint: false + } + }; + } + + rpc CompleteTask(CompleteTaskRequest) returns (CompleteTaskResponse) { + option (mcp.options.v1.method) = { + name: "CompleteTask" + title: "Complete Task" + description: "Mark a task as completed." + annotations: { + destructive_hint: false + idempotent_hint: true + open_world_hint: false + } + }; + } +} + +message Task { + string id = 1 [(mcp.options.v1.field) = { + description: "Stable task id returned by CreateTask." + examples: [{string_value: "task-1"}] + }]; + string title = 2 [(mcp.options.v1.field) = { + description: "Human-readable task title." + examples: [{string_value: "Draft release notes"}] + min_length: 1 + max_length: 120 + }]; + bool done = 3 [(mcp.options.v1.field) = { + description: "Whether the task is completed." + }]; + repeated string labels = 4 [(mcp.options.v1.field) = { + description: "Optional labels used by the caller to organize work." + examples: [{array_value: {items: [{string_value: "docs"}, {string_value: "release"}]}}] + }]; +} + +message CreateTaskRequest { + string title = 1 [(mcp.options.v1.field) = { + description: "Title for the new task." + examples: [{string_value: "Draft release notes"}] + min_length: 1 + max_length: 120 + }]; + repeated string labels = 2 [(mcp.options.v1.field) = { + description: "Optional labels to attach to the task." + examples: [{array_value: {items: [{string_value: "docs"}]}}] + }]; +} + +message CreateTaskResponse { + Task task = 1; +} + +message ListTasksRequest { + optional bool include_done = 1 [(mcp.options.v1.field) = { + description: "When false, completed tasks are omitted." + default_value: {bool_value: true} + }]; +} + +message ListTasksResponse { + repeated Task tasks = 1; +} + +message CompleteTaskRequest { + string id = 1 [(mcp.options.v1.field) = { + description: "Task id to mark completed." + examples: [{string_value: "task-1"}] + min_length: 1 + }]; +} + +message CompleteTaskResponse { + Task task = 1; +} diff --git a/examples/6_java_standalone/settings.gradle.kts b/examples/6_java_standalone/settings.gradle.kts new file mode 100644 index 0000000..1c080a8 --- /dev/null +++ b/examples/6_java_standalone/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + google() + } +} + +rootProject.name = "todo-java-standalone" diff --git a/examples/6_java_standalone/src/main/java/com/example/todo/java/TodoJavaServer.java b/examples/6_java_standalone/src/main/java/com/example/todo/java/TodoJavaServer.java new file mode 100644 index 0000000..8263dbc --- /dev/null +++ b/examples/6_java_standalone/src/main/java/com/example/todo/java/TodoJavaServer.java @@ -0,0 +1,73 @@ +package com.example.todo.java; + +import com.example.todo.v1.TodoMcp; +import com.example.todo.v1.TodoProto; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class TodoJavaServer { + private TodoJavaServer() { + } + + public static void main(String[] args) { + StdioServerTransportProvider transportProvider = + new StdioServerTransportProvider(McpJsonDefaults.getMapper()); + TodoMcp.registerTodoAPITools(transportProvider, new Handler(), "todo"); + } + + private static final class Handler implements TodoMcp.TodoAPIToolHandler { + private final Map tasks = new LinkedHashMap<>(); + private int nextID = 1; + + @Override + public TodoProto.CreateTaskResponse createTask( + McpAsyncServerExchange ctx, + TodoProto.CreateTaskRequest request + ) { + String id = "task-" + nextID++; + TodoProto.Task task = TodoProto.Task.newBuilder() + .setId(id) + .setTitle(request.getTitle()) + .addAllLabels(request.getLabelsList()) + .build(); + tasks.put(id, task); + return TodoProto.CreateTaskResponse.newBuilder() + .setTask(task) + .build(); + } + + @Override + public TodoProto.ListTasksResponse listTasks( + McpAsyncServerExchange ctx, + TodoProto.ListTasksRequest request + ) { + boolean includeDone = !request.hasIncludeDone() || request.getIncludeDone(); + TodoProto.ListTasksResponse.Builder response = TodoProto.ListTasksResponse.newBuilder(); + for (TodoProto.Task task : tasks.values()) { + if (includeDone || !task.getDone()) { + response.addTasks(task); + } + } + return response.build(); + } + + @Override + public TodoProto.CompleteTaskResponse completeTask( + McpAsyncServerExchange ctx, + TodoProto.CompleteTaskRequest request + ) { + TodoProto.Task existing = tasks.get(request.getId()); + if (existing == null) { + throw new IllegalArgumentException("unknown task id: " + request.getId()); + } + TodoProto.Task completed = existing.toBuilder().setDone(true).build(); + tasks.put(completed.getId(), completed); + return TodoProto.CompleteTaskResponse.newBuilder() + .setTask(completed) + .build(); + } + } +} diff --git a/examples/7_kotlin_standalone/.gitignore b/examples/7_kotlin_standalone/.gitignore new file mode 100644 index 0000000..0fbaf99 --- /dev/null +++ b/examples/7_kotlin_standalone/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +src/generated/ +google/ diff --git a/examples/7_kotlin_standalone/Makefile b/examples/7_kotlin_standalone/Makefile new file mode 100644 index 0000000..27afa88 --- /dev/null +++ b/examples/7_kotlin_standalone/Makefile @@ -0,0 +1,19 @@ +.PHONY: build clean generate lint run + +generate: + easyp --cfg easyp.yaml mod download + easyp --cfg easyp.yaml generate -p proto -r . + rm -rf google src/generated/main/java/com/google + +lint: + easyp --cfg easyp.yaml mod download + easyp --cfg easyp.yaml lint -p proto -r . + +build: + gradle --no-daemon build + +run: + gradle --no-daemon run + +clean: + rm -rf .gradle build src/generated google diff --git a/examples/7_kotlin_standalone/README.md b/examples/7_kotlin_standalone/README.md new file mode 100644 index 0000000..6c95a60 --- /dev/null +++ b/examples/7_kotlin_standalone/README.md @@ -0,0 +1,51 @@ +# Standalone Kotlin MCP Server + +This example is structured like a user-owned Kotlin project. It has its own +`easyp.yaml`, Gradle build, protobuf contract, generated source directories, +and stdio MCP server. + +## Generate + +```bash +make generate +``` + +`easyp.yaml` generates three source sets: + +- Java protobuf classes in `src/generated/main/java` +- Kotlin protobuf helpers in `src/generated/main/kotlin` +- the `lang=kotlin` MCP sidecar in `src/generated/main/kotlin` + +The proto is JVM-native and intentionally does not declare `go_package`. +`protoc-gen-mcp` synthesizes the internal metadata that `protogen` needs for +`lang=kotlin`. + +`with_imports: true` on Java protobuf generation is used so the local +`mcp.options.v1` Java extension class is generated from the Easyp dependency. +The build removes generated `com.google.protobuf` sources afterward and uses +the protobuf Java jar for Google runtime classes. + +The local repository version uses: + +```yaml +command: ["go", "run", "../../cmd/protoc-gen-mcp"] +``` + +In an external project, replace it with the released plugin binary or module +entrypoint you want to pin. + +## Build And Run + +```bash +make build +make run +``` + +The server registers generated tools through: + +```kotlin +registerTodoAPITools(server, Handler(), namespace = "todo") +``` + +The handwritten server lives under `src/main/kotlin` and implements the +generated `TodoAPIToolHandler` interface. diff --git a/examples/7_kotlin_standalone/build.gradle.kts b/examples/7_kotlin_standalone/build.gradle.kts new file mode 100644 index 0000000..25f741a --- /dev/null +++ b/examples/7_kotlin_standalone/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + application + kotlin("jvm") version "2.3.10" + java +} + +dependencies { + implementation("io.modelcontextprotocol:kotlin-sdk-server:0.11.1") + implementation("com.google.protobuf:protobuf-java:4.34.1") + implementation("com.google.protobuf:protobuf-kotlin:4.34.1") + implementation("com.google.protobuf:protobuf-java-util:4.34.1") + implementation("com.networknt:json-schema-validator:1.5.9") + runtimeOnly("org.slf4j:slf4j-nop:2.0.17") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) + sourceSets.named("main") { + kotlin.srcDir("src/generated/main/kotlin") + } +} + +sourceSets { + named("main") { + java { + srcDir("src/generated/main/java") + } + } +} + +application { + mainClass.set("com.example.todo.kotlin.TodoKotlinServerKt") +} + +tasks.register("easypModDownload") { + group = "build" + description = "Download protobuf dependencies declared in easyp.yaml." + commandLine("easyp", "--cfg", "easyp.yaml", "mod", "download") +} + +tasks.register("generateProto") { + group = "build" + description = "Generate protobuf Java/Kotlin classes and MCP Kotlin sidecars." + dependsOn("easypModDownload") + commandLine("easyp", "--cfg", "easyp.yaml", "generate", "-p", "proto", "-r", ".") + doLast { + delete("google", "src/generated/main/java/com/google") + } +} + +tasks.named("compileJava") { + dependsOn("generateProto") +} + +tasks.named("compileKotlin") { + dependsOn("generateProto") +} + +tasks.named("clean") { + doLast { + delete("src/generated", "google") + } +} diff --git a/examples/7_kotlin_standalone/easyp.lock b/examples/7_kotlin_standalone/easyp.lock new file mode 100644 index 0000000..c30dd0d --- /dev/null +++ b/examples/7_kotlin_standalone/easyp.lock @@ -0,0 +1 @@ +github.com/easyp-tech/protoc-gen-mcp v0.4.0^{} h1:89szb16JOQw4+3VcouZEvsW8Kh6XMZi8kntiD8OOvcI= diff --git a/examples/7_kotlin_standalone/easyp.yaml b/examples/7_kotlin_standalone/easyp.yaml new file mode 100644 index 0000000..6ee7c4a --- /dev/null +++ b/examples/7_kotlin_standalone/easyp.yaml @@ -0,0 +1,37 @@ +deps: + - github.com/easyp-tech/protoc-gen-mcp + +lint: + use: + - PACKAGE_DEFINED + - PACKAGE_LOWER_SNAKE_CASE + - PACKAGE_VERSION_SUFFIX + - FILE_LOWER_SNAKE_CASE + - MESSAGE_PASCAL_CASE + - FIELD_LOWER_SNAKE_CASE + - RPC_PASCAL_CASE + - SERVICE_PASCAL_CASE + - SERVICE_SUFFIX + - RPC_REQUEST_RESPONSE_UNIQUE + - RPC_REQUEST_STANDARD_NAME + - RPC_RESPONSE_STANDARD_NAME + - RPC_NO_CLIENT_STREAMING + - RPC_NO_SERVER_STREAMING + service_suffix: API + +generate: + inputs: + - directory: + path: proto + root: "." + plugins: + - name: java + with_imports: true + out: src/generated/main/java + - name: kotlin + out: src/generated/main/kotlin + - command: ["go", "run", "../../cmd/protoc-gen-mcp"] + out: src/generated/main/kotlin + opts: + paths: source_relative + lang: kotlin diff --git a/examples/7_kotlin_standalone/proto/todo.proto b/examples/7_kotlin_standalone/proto/todo.proto new file mode 100644 index 0000000..326ae2c --- /dev/null +++ b/examples/7_kotlin_standalone/proto/todo.proto @@ -0,0 +1,115 @@ +syntax = "proto3"; + +package standalone.todo.v1; + +import "mcp/options/v1/options.proto"; + +option java_package = "com.example.todo.v1"; +option java_outer_classname = "TodoProto"; + +service TodoAPI { + option (mcp.options.v1.service) = { + namespace: "todo" + description: "Todo tools exposed by a standalone Kotlin MCP server." + }; + + rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse) { + option (mcp.options.v1.method) = { + name: "CreateTask" + title: "Create Task" + description: "Create a task in the in-memory todo list." + annotations: { + destructive_hint: false + idempotent_hint: false + open_world_hint: false + } + }; + } + + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse) { + option (mcp.options.v1.method) = { + name: "ListTasks" + title: "List Tasks" + description: "List current tasks from the in-memory todo list." + annotations: { + read_only_hint: true + destructive_hint: false + idempotent_hint: true + open_world_hint: false + } + }; + } + + rpc CompleteTask(CompleteTaskRequest) returns (CompleteTaskResponse) { + option (mcp.options.v1.method) = { + name: "CompleteTask" + title: "Complete Task" + description: "Mark a task as completed." + annotations: { + destructive_hint: false + idempotent_hint: true + open_world_hint: false + } + }; + } +} + +message Task { + string id = 1 [(mcp.options.v1.field) = { + description: "Stable task id returned by CreateTask." + examples: [{string_value: "task-1"}] + }]; + string title = 2 [(mcp.options.v1.field) = { + description: "Human-readable task title." + examples: [{string_value: "Draft release notes"}] + min_length: 1 + max_length: 120 + }]; + bool done = 3 [(mcp.options.v1.field) = { + description: "Whether the task is completed." + }]; + repeated string labels = 4 [(mcp.options.v1.field) = { + description: "Optional labels used by the caller to organize work." + examples: [{array_value: {items: [{string_value: "docs"}, {string_value: "release"}]}}] + }]; +} + +message CreateTaskRequest { + string title = 1 [(mcp.options.v1.field) = { + description: "Title for the new task." + examples: [{string_value: "Draft release notes"}] + min_length: 1 + max_length: 120 + }]; + repeated string labels = 2 [(mcp.options.v1.field) = { + description: "Optional labels to attach to the task." + examples: [{array_value: {items: [{string_value: "docs"}]}}] + }]; +} + +message CreateTaskResponse { + Task task = 1; +} + +message ListTasksRequest { + optional bool include_done = 1 [(mcp.options.v1.field) = { + description: "When false, completed tasks are omitted." + default_value: {bool_value: true} + }]; +} + +message ListTasksResponse { + repeated Task tasks = 1; +} + +message CompleteTaskRequest { + string id = 1 [(mcp.options.v1.field) = { + description: "Task id to mark completed." + examples: [{string_value: "task-1"}] + min_length: 1 + }]; +} + +message CompleteTaskResponse { + Task task = 1; +} diff --git a/examples/7_kotlin_standalone/settings.gradle.kts b/examples/7_kotlin_standalone/settings.gradle.kts new file mode 100644 index 0000000..fa8cc89 --- /dev/null +++ b/examples/7_kotlin_standalone/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + google() + } +} + +rootProject.name = "todo-kotlin-standalone" diff --git a/examples/7_kotlin_standalone/src/main/kotlin/com/example/todo/kotlin/TodoKotlinServer.kt b/examples/7_kotlin_standalone/src/main/kotlin/com/example/todo/kotlin/TodoKotlinServer.kt new file mode 100644 index 0000000..556c8b7 --- /dev/null +++ b/examples/7_kotlin_standalone/src/main/kotlin/com/example/todo/kotlin/TodoKotlinServer.kt @@ -0,0 +1,90 @@ +package com.example.todo.kotlin + +import com.example.todo.v1.TodoAPIToolHandler +import com.example.todo.v1.TodoProto +import com.example.todo.v1.registerTodoAPITools +import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered + +fun main() = runBlocking { + disableKotlinLoggingStartupMessage() + + val closed = CompletableDeferred() + val server = Server( + Implementation(name = "todo-kotlin-standalone", version = "0.1.0"), + ServerOptions(ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false))), + ) + server.onClose { closed.complete(Unit) } + + registerTodoAPITools(server, Handler(), namespace = "todo") + + val transport = StdioServerTransport( + System.`in`.asSource().buffered(), + System.out.asSink().buffered(), + ) + server.createSession(transport) + closed.await() +} + +private fun disableKotlinLoggingStartupMessage() { + runCatching { + val configClass = Class.forName("io.github.oshai.kotlinlogging.KotlinLoggingConfiguration") + val instance = configClass.getField("INSTANCE").get(null) + val setter = configClass.getMethod("setLogStartupMessage", Boolean::class.javaPrimitiveType) + setter.invoke(instance, false) + } +} + +private class Handler : TodoAPIToolHandler { + private val tasks = linkedMapOf() + private var nextId = 1 + + override suspend fun createTask( + ctx: ClientConnection, + request: TodoProto.CreateTaskRequest, + ): TodoProto.CreateTaskResponse { + val id = "task-${nextId++}" + val task = TodoProto.Task.newBuilder() + .setId(id) + .setTitle(request.title) + .addAllLabels(request.labelsList) + .build() + tasks[id] = task + return TodoProto.CreateTaskResponse.newBuilder() + .setTask(task) + .build() + } + + override suspend fun listTasks( + ctx: ClientConnection, + request: TodoProto.ListTasksRequest, + ): TodoProto.ListTasksResponse { + val includeDone = !request.hasIncludeDone() || request.includeDone + val response = TodoProto.ListTasksResponse.newBuilder() + tasks.values + .filter { includeDone || !it.done } + .forEach { response.addTasks(it) } + return response.build() + } + + override suspend fun completeTask( + ctx: ClientConnection, + request: TodoProto.CompleteTaskRequest, + ): TodoProto.CompleteTaskResponse { + val existing = tasks[request.id] ?: error("unknown task id: ${request.id}") + val completed = existing.toBuilder().setDone(true).build() + tasks[completed.id] = completed + return TodoProto.CompleteTaskResponse.newBuilder() + .setTask(completed) + .build() + } +} diff --git a/examples/Makefile b/examples/Makefile index f3117ea..843c1d3 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -1,12 +1,21 @@ -.PHONY: generate lint +.PHONY: clean-jvm-build-artifacts generate lint + +clean-jvm-build-artifacts: + rm -rf jvm/*/build 6_java_standalone/build 7_kotlin_standalone/build generate: + $(MAKE) clean-jvm-build-artifacts easyp --cfg easyp.yaml mod download easyp --cfg easyp.yaml generate -p . -r . rm -rf google $(MAKE) -C 5_python_standalone generate + $(MAKE) -C 6_java_standalone generate + $(MAKE) -C 7_kotlin_standalone generate lint: + $(MAKE) clean-jvm-build-artifacts easyp --cfg easyp.yaml mod download easyp --cfg easyp.yaml lint -p . -r . $(MAKE) -C 5_python_standalone lint + $(MAKE) -C 6_java_standalone lint + $(MAKE) -C 7_kotlin_standalone lint diff --git a/examples/README.md b/examples/README.md index bcb5461..fc4a307 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,11 +6,11 @@ Python, Kotlin, and Java SDKs. ## Prerequisites - **easyp**: Ensure you have [easyp](https://github.com/easyp-tech/easyp) installed for code generation. -- **JDK 17+ and Gradle 9.2+**: Required for the dedicated JVM workspace under - [`examples/jvm`](./jvm/README.md). +- **JDK 17+ and Gradle 9.2+**: Required for the standalone Java/Kotlin projects + and the dedicated JVM workspace under [`examples/jvm`](./jvm/README.md). ## Running the Examples -1. Generate the Go and Python example artifacts from the `.proto` definitions. +1. Generate the example artifacts from the `.proto` definitions. Run this from the root of the repository or from the examples directory: ```bash cd examples @@ -24,8 +24,9 @@ Python, Kotlin, and Java SDKs. through the `deps` entry in `examples/easyp.yaml` and pinned by `examples/easyp.lock`, so the examples do not depend on the local repository-root options package during generation. - `5_python_standalone` has its own `easyp.yaml`, `easyp.lock`, and - `pyproject.toml` to model a user-owned Python project. + `5_python_standalone`, `6_java_standalone`, and + `7_kotlin_standalone` each have their own `easyp.yaml` and lockfile to + model user-owned projects. 2. Run any of the Go servers (e.g. `1_helloworld`): ```bash cd examples/1_helloworld @@ -42,7 +43,15 @@ Python, Kotlin, and Java SDKs. make setup make run ``` -5. Run the Java/Kotlin workspace: +5. Build or run the standalone JVM projects: + ```bash + cd examples/6_java_standalone + make build + + cd ../7_kotlin_standalone + make build + ``` +6. Run the Java/Kotlin workspace: Follow [`examples/jvm/README.md`](./jvm/README.md) for the tested `compileJava` / `compileKotlin`, `installDist`, and installed-script stdio flow. @@ -87,6 +96,27 @@ protobuf contract, generated bindings, and stdio server. - **No import hacks**: `server.py` imports generated code with `from proto import notebook_mcp` and does not edit `sys.path`. - **Python-only proto**: the user-authored proto does not need `go_package`; `protoc-gen-mcp` synthesizes internal metadata for Python generation. +### [6_java_standalone](./6_java_standalone) (Java User Project) +A standalone Java MCP server with its own Gradle build, `easyp.yaml`, protobuf +contract, and handwritten stdio server. +- **Java-native proto**: the user-authored proto uses `java_package` and does + not need a Go `go_package` option. +- **Local options generation**: `with_imports: true` generates the local + `mcp.options.v1` Java class from the Easyp dependency, while the build + removes generated Google protobuf sources and relies on the protobuf jar. +- **Generated API**: the server implements `TodoMcp.TodoAPIToolHandler` and + registers tools through `TodoMcp.registerTodoAPITools(...)`. + +### [7_kotlin_standalone](./7_kotlin_standalone) (Kotlin User Project) +A standalone Kotlin MCP server with its own Gradle build, `easyp.yaml`, +protobuf contract, and handwritten stdio server. +- **Dual protobuf generation**: `easyp.yaml` generates Java protobuf classes, + Kotlin protobuf helpers, and the `lang=kotlin` MCP sidecar. +- **Kotlin-native handler**: the server implements `TodoAPIToolHandler` and + registers tools through `registerTodoAPITools(server, impl, namespace = ...)`. +- **No Go proto metadata**: JVM request preparation synthesizes internal + protogen metadata, so the user-authored proto does not need `go_package`. + ### [jvm](./jvm/README.md) (Java & Kotlin Official SDK Workspace) An isolated Gradle workspace that generates and runs Java and Kotlin MCP servers against the official JVM SDKs. diff --git a/examples/python_stdio_test.go b/examples/python_stdio_test.go index 0882fff..2280cb1 100644 --- a/examples/python_stdio_test.go +++ b/examples/python_stdio_test.go @@ -3,13 +3,13 @@ package examples_test import ( "context" "encoding/json" - "os" "os/exec" "path/filepath" "reflect" "runtime" "testing" + "github.com/easyp-tech/protoc-gen-mcp/internal/pythontest" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -328,16 +328,9 @@ func repoRoot(t *testing.T) string { func pythonExampleCommand(t *testing.T, root, script string) *exec.Cmd { t.Helper() - python := pythonCommand(t) - probe := exec.Command(python, "-c", "import anyio, google.protobuf, mcp") - probe.Dir = root - if output, err := probe.CombinedOutput(); err != nil { - t.Fatalf("python runtime dependencies are not available: %v\n%s", err, output) - } - - cmd := exec.Command(python, script) + cmd := exec.Command(pythontest.Python(t), script) cmd.Dir = root - cmd.Env = append(os.Environ(), + cmd.Env = pythontest.Env(t, "PYTHONPATH=", "PYTHONUNBUFFERED=1", ) @@ -347,52 +340,15 @@ func pythonExampleCommand(t *testing.T, root, script string) *exec.Cmd { func standalonePythonExampleCommand(t *testing.T, projectDir string) *exec.Cmd { t.Helper() - python := standalonePythonCommand(t, projectDir) - probe := exec.Command(python, "-c", "import anyio, google.protobuf, jsonschema, mcp") - probe.Dir = projectDir - if output, err := probe.CombinedOutput(); err != nil { - t.Fatalf("python runtime dependencies are not available: %v\n%s", err, output) - } - - cmd := exec.Command(python, "server.py") + cmd := exec.Command(pythontest.Python(t), "server.py") cmd.Dir = projectDir - cmd.Env = append(os.Environ(), + cmd.Env = pythontest.Env(t, "PYTHONPATH=", "PYTHONUNBUFFERED=1", ) return cmd } -func standalonePythonCommand(t *testing.T, projectDir string) string { - t.Helper() - - candidates := []string{ - filepath.Join(projectDir, ".venv", "bin", "python"), - filepath.Join(projectDir, ".venv", "Scripts", "python.exe"), - } - for _, candidate := range candidates { - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - - return pythonCommand(t) -} - -func pythonCommand(t *testing.T) string { - t.Helper() - - if path, err := exec.LookPath("python3"); err == nil { - return path - } - if path, err := exec.LookPath("python"); err == nil { - return path - } - - t.Fatal("python3/python not found in PATH") - return "" -} - func decodeMap(t *testing.T, value any) map[string]any { t.Helper() diff --git a/go.mod b/go.mod index b470c0c..301110a 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.26 require ( github.com/bufbuild/protocompile v0.14.1 - github.com/google/jsonschema-go v0.4.2 - github.com/modelcontextprotocol/go-sdk v1.4.1 + github.com/google/jsonschema-go v0.4.3 + github.com/modelcontextprotocol/go-sdk v1.6.0 google.golang.org/protobuf v1.36.11 pgregory.net/rapid v1.2.0 ) diff --git a/go.sum b/go.sum index 35d54b4..1193092 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,14 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/ github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= -github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= +github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= @@ -26,8 +26,8 @@ golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/codegen/python_contract_test.go b/internal/codegen/python_contract_test.go index 9977c75..5e374b7 100644 --- a/internal/codegen/python_contract_test.go +++ b/internal/codegen/python_contract_test.go @@ -616,6 +616,62 @@ func TestPythonRenderer_EmitsPythonBoolLiteralsInAnnotations(t *testing.T) { } } +func TestPythonRenderer_ProjectsTaskSupportExecution(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/execution.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `import "mcp/options/v1/options.proto";`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/execution;executionv1";`, + `message ExecutionRequest {}`, + `message ExecutionResponse {}`, + `service ExecutionAPI {`, + ` rpc OptionalTask(ExecutionRequest) returns (ExecutionResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` execution: { task_support: TASK_SUPPORT_OPTIONAL }`, + ` };`, + ` }`, + ` rpc RequiredTask(ExecutionRequest) returns (ExecutionResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` execution: { task_support: TASK_SUPPORT_REQUIRED }`, + ` };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/execution.proto") + + file := plugin.FilesByPath["test/v1/execution.proto"] + if file == nil { + t.Fatal("execution proto file not found in plugin") + } + + model, err := CollectFileModel(file, Options{ + Language: LanguagePython, + PythonRuntime: PythonRuntimeGoogleProtobuf, + }) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + if err := renderPythonFile(plugin, model); err != nil { + t.Fatalf("renderPythonFile: %v", err) + } + + generated := string(generatedFileContent(t, plugin, "test/v1/execution_mcp.py")) + wantSnippets := []string{ + "execution: dict[str, Any] | None = None", + "def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None:", + "execution=_tool_execution(tool.execution),", + `execution={"taskSupport": "optional"}`, + `execution={"taskSupport": "required"}`, + } + for _, snippet := range wantSnippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated python missing execution snippet %q\n%s", snippet, generated) + } + } +} + func TestPythonModuleAlias_AvoidsImportedModuleCollisions(t *testing.T) { plugin := newTempProtogenPlugin(t, map[string]string{ "a/b_c.proto": strings.Join([]string{ diff --git a/internal/codegen/python_mapper_test.go b/internal/codegen/python_mapper_test.go index cc101e6..c820652 100644 --- a/internal/codegen/python_mapper_test.go +++ b/internal/codegen/python_mapper_test.go @@ -3,10 +3,11 @@ package codegen import ( "fmt" "os" - "os/exec" "path/filepath" "runtime" "testing" + + "github.com/easyp-tech/protoc-gen-mcp/internal/pythontest" ) func renderExamplePythonForMapperTests(t *testing.T) string { @@ -85,19 +86,14 @@ func prepareExamplePythonRuntime(t *testing.T) (tempRoot string, repoRoot string func runExamplePythonScript(t *testing.T, script string) { t.Helper() - python, err := exec.LookPath("python3") - if err != nil { - t.Skipf("python3 not available: %v", err) - } - tempRoot, repoRoot := prepareExamplePythonRuntime(t) - cmd := exec.Command(python, "-c", fmt.Sprintf( + cmd := pythontest.Command(t, "-c", fmt.Sprintf( "from internal.testproto.example.v1 import example_mcp as module\n"+ "from internal.testproto.example.v1 import example_pb2\n%s", script, )) cmd.Dir = tempRoot - cmd.Env = append(os.Environ(), "PYTHONPATH="+tempRoot+string(os.PathListSeparator)+repoRoot) + cmd.Env = pythontest.Env(t, "PYTHONPATH="+tempRoot+string(os.PathListSeparator)+repoRoot) output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/codegen/render_python.go b/internal/codegen/render_python.go index 323dc68..7964aae 100644 --- a/internal/codegen/render_python.go +++ b/internal/codegen/render_python.go @@ -141,6 +141,7 @@ func renderPythonFile(plugin *protogen.Plugin, model FileModel) error { generated.P(" handler=impl.", pythonMethodName(method.ProtoName), ",") generated.P(" annotations=", pythonAnnotations(method.Annotations), ",") generated.P(" icons=", pythonIcons(method.Icons), ",") + generated.P(" execution=", pythonTaskSupport(method.TaskSupport), ",") generated.P(" ))") } generated.P() @@ -384,3 +385,14 @@ func pythonIcons(icons []*mcpoptionsv1.Icon) string { return "[" + strings.Join(items, ", ") + "]" } + +func pythonTaskSupport(taskSupport mcpoptionsv1.TaskSupport) string { + switch taskSupport { + case mcpoptionsv1.TaskSupport_TASK_SUPPORT_OPTIONAL: + return `{"taskSupport": "optional"}` + case mcpoptionsv1.TaskSupport_TASK_SUPPORT_REQUIRED: + return `{"taskSupport": "required"}` + default: + return "None" + } +} diff --git a/internal/codegen/render_python_runtime.go b/internal/codegen/render_python_runtime.go index fc13a79..064dd16 100644 --- a/internal/codegen/render_python_runtime.go +++ b/internal/codegen/render_python_runtime.go @@ -17,6 +17,7 @@ func renderPythonRuntime(generated *protogen.GeneratedFile) { generated.P(" handler: Any") generated.P(" annotations: dict[str, Any] | None") generated.P(" icons: list[dict[str, Any]] | None") + generated.P(" execution: dict[str, Any] | None = None") generated.P() generated.P("class _ServerToolRegistry:") generated.P(" def __init__(self, server: mcp.server.lowlevel.Server) -> None:") @@ -158,6 +159,11 @@ func renderPythonRuntime(generated *protogen.GeneratedFile) { generated.P(" return None") generated.P(" return mcp.types.ToolAnnotations(**raw)") generated.P() + generated.P("def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None:") + generated.P(" if raw is None:") + generated.P(" return None") + generated.P(" return mcp.types.ToolExecution(**raw)") + generated.P() generated.P("def _tool_error_result(message: str) -> mcp.types.CallToolResult:") generated.P(" return mcp.types.CallToolResult(") generated.P(" content=[mcp.types.TextContent(type=\"text\", text=message)],") @@ -181,6 +187,7 @@ func renderPythonRuntime(generated *protogen.GeneratedFile) { generated.P(" outputSchema=_load_schema(tool.output_schema_json),") generated.P(" annotations=_tool_annotations(tool.annotations),") generated.P(" icons=tool.icons,") + generated.P(" execution=_tool_execution(tool.execution),") generated.P(" )") generated.P() generated.P("async def _maybe_await(result: Any) -> Any:") diff --git a/internal/codegen/python_request.go b/internal/codegen/request.go similarity index 62% rename from internal/codegen/python_request.go rename to internal/codegen/request.go index ffc133b..b271793 100644 --- a/internal/codegen/python_request.go +++ b/internal/codegen/request.go @@ -9,13 +9,13 @@ import ( "google.golang.org/protobuf/types/pluginpb" ) -const pythonSyntheticGoPackageName = "mcppython" +const syntheticGoPackageName = "mcpgenerated" // PrepareRequestForProtogen adjusts the raw protoc request before protogen -// validates Go-specific metadata. Python generation does not use Go import -// paths, but protogen still requires them to build its descriptor model. +// validates Go-specific metadata. Python and JVM generation do not use Go +// import paths, but protogen still requires them to build its descriptor model. func PrepareRequestForProtogen(req *pluginpb.CodeGeneratorRequest, opts Options) { - if req == nil || opts.Language != LanguagePython { + if req == nil || !requiresSyntheticGoPackage(opts.Language) { return } @@ -26,18 +26,27 @@ func PrepareRequestForProtogen(req *pluginpb.CodeGeneratorRequest, opts Options) if file.Options == nil { file.Options = &descriptorpb.FileOptions{} } - file.Options.GoPackage = proto.String(syntheticPythonGoPackage(file)) + file.Options.GoPackage = proto.String(syntheticGoPackage(file, opts.Language)) } } -func syntheticPythonGoPackage(file *descriptorpb.FileDescriptorProto) string { +func requiresSyntheticGoPackage(language Language) bool { + switch language { + case LanguagePython, LanguageKotlin, LanguageJava: + return true + default: + return false + } +} + +func syntheticGoPackage(file *descriptorpb.FileDescriptorProto, language Language) string { name := strings.TrimSuffix(file.GetName(), ".proto") name = strings.Trim(path.Clean(name), "/.") if name == "" { name = "unknown" } - return "protoc-gen-mcp.local/python/" + sanitizeSyntheticGoImportPath(name) + ";" + pythonSyntheticGoPackageName + return "protoc-gen-mcp.local/" + string(language) + "/" + sanitizeSyntheticGoImportPath(name) + ";" + syntheticGoPackageName } func sanitizeSyntheticGoImportPath(value string) string { diff --git a/internal/codegen/python_request_test.go b/internal/codegen/request_test.go similarity index 50% rename from internal/codegen/python_request_test.go rename to internal/codegen/request_test.go index fbcd649..726226e 100644 --- a/internal/codegen/python_request_test.go +++ b/internal/codegen/request_test.go @@ -31,6 +31,32 @@ func TestPrepareRequestForProtogen_SynthesizesGoPackageForPython(t *testing.T) { } } +func TestPrepareRequestForProtogen_SynthesizesGoPackageForJVM(t *testing.T) { + for _, language := range []Language{LanguageKotlin, LanguageJava} { + t.Run(string(language), func(t *testing.T) { + req := &pluginpb.CodeGeneratorRequest{ + ProtoFile: []*descriptorpb.FileDescriptorProto{ + { + Name: proto.String("proto/notebook.proto"), + Package: proto.String("notebook.v1"), + Options: &descriptorpb.FileOptions{}, + }, + }, + } + + PrepareRequestForProtogen(req, Options{Language: language}) + + got := req.ProtoFile[0].GetOptions().GetGoPackage() + if got == "" { + t.Fatal("GoPackage was not synthesized") + } + if got == "notebook.v1" { + t.Fatalf("GoPackage = %q, want an import path with explicit package suffix", got) + } + }) + } +} + func TestPrepareRequestForProtogen_DoesNotSynthesizeGoPackageForGo(t *testing.T) { req := &pluginpb.CodeGeneratorRequest{ ProtoFile: []*descriptorpb.FileDescriptorProto{ @@ -69,31 +95,35 @@ func TestPrepareRequestForProtogen_PreservesExplicitGoPackage(t *testing.T) { } } -func TestPrepareRequestForProtogen_AllowsPythonProtogenNewWithoutGoPackage(t *testing.T) { - req := &pluginpb.CodeGeneratorRequest{ - Parameter: proto.String("paths=source_relative,lang=python"), - FileToGenerate: []string{"proto/notebook.proto"}, - ProtoFile: []*descriptorpb.FileDescriptorProto{ - { - Name: proto.String("proto/notebook.proto"), - Package: proto.String("notebook.v1"), - Syntax: proto.String("proto3"), - Options: &descriptorpb.FileOptions{}, - }, - }, - } - opts, err := ParseOptions(req.GetParameter()) - if err != nil { - t.Fatalf("ParseOptions: %v", err) - } - PrepareRequestForProtogen(req, opts) +func TestPrepareRequestForProtogen_AllowsNonGoProtogenNewWithoutGoPackage(t *testing.T) { + for _, language := range []Language{LanguagePython, LanguageKotlin, LanguageJava} { + t.Run(string(language), func(t *testing.T) { + req := &pluginpb.CodeGeneratorRequest{ + Parameter: proto.String("paths=source_relative,lang=" + string(language)), + FileToGenerate: []string{"proto/notebook.proto"}, + ProtoFile: []*descriptorpb.FileDescriptorProto{ + { + Name: proto.String("proto/notebook.proto"), + Package: proto.String("notebook.v1"), + Syntax: proto.String("proto3"), + Options: &descriptorpb.FileOptions{}, + }, + }, + } + opts, err := ParseOptions(req.GetParameter()) + if err != nil { + t.Fatalf("ParseOptions: %v", err) + } + PrepareRequestForProtogen(req, opts) - parser := NewOptionsParser() - plugin, err := (protogen.Options{ParamFunc: parser.Set}).New(req) - if err != nil { - t.Fatalf("protogen.Options.New() failed after Python request preparation: %v", err) - } - if got := plugin.FilesByPath["proto/notebook.proto"].GoImportPath; got == "" { - t.Fatal("GoImportPath is empty after request preparation") + parser := NewOptionsParser() + plugin, err := (protogen.Options{ParamFunc: parser.Set}).New(req) + if err != nil { + t.Fatalf("protogen.Options.New() failed after %s request preparation: %v", language, err) + } + if got := plugin.FilesByPath["proto/notebook.proto"].GoImportPath; got == "" { + t.Fatal("GoImportPath is empty after request preparation") + } + }) } } diff --git a/internal/examplemcp/stdio_test.go b/internal/examplemcp/stdio_test.go index 701ba3f..1687487 100644 --- a/internal/examplemcp/stdio_test.go +++ b/internal/examplemcp/stdio_test.go @@ -3,7 +3,6 @@ package examplemcp_test import ( "context" "encoding/json" - "os" "os/exec" "path/filepath" "reflect" @@ -13,6 +12,7 @@ import ( "testing" "github.com/easyp-tech/protoc-gen-mcp/internal/examplemcp" + "github.com/easyp-tech/protoc-gen-mcp/internal/pythontest" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -307,36 +307,15 @@ func repoRoot(t *testing.T) string { func pythonExampleServerCommand(t *testing.T, root string) *exec.Cmd { t.Helper() - python := pythonCommand(t) - probe := exec.Command(python, "-c", "import anyio, google.protobuf, jsonschema, mcp") - probe.Dir = root - if output, err := probe.CombinedOutput(); err != nil { - t.Fatalf("python runtime dependencies are not available: %v\n%s", err, output) - } - - cmd := exec.Command(python, filepath.Join(root, "cmd/example-python-mcp-server/main.py")) + cmd := exec.Command(pythontest.Python(t), filepath.Join(root, "cmd/example-python-mcp-server/main.py")) cmd.Dir = root - cmd.Env = append(os.Environ(), + cmd.Env = pythontest.Env(t, "PYTHONPATH="+root, "PYTHONUNBUFFERED=1", ) return cmd } -func pythonCommand(t *testing.T) string { - t.Helper() - - if path, err := exec.LookPath("python3"); err == nil { - return path - } - if path, err := exec.LookPath("python"); err == nil { - return path - } - - t.Fatal("python3/python not found in PATH") - return "" -} - func decodeMap(t *testing.T, value any) map[string]any { t.Helper() diff --git a/internal/pythontest/env.go b/internal/pythontest/env.go new file mode 100644 index 0000000..4d6d09b --- /dev/null +++ b/internal/pythontest/env.go @@ -0,0 +1,240 @@ +package pythontest + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +const ( + cacheVersion = "v1" + probeScript = "import anyio, jsonschema, mcp\nimport google.protobuf\nmajor = int(google.protobuf.__version__.split('.', 1)[0])\nassert 6 <= major < 7, google.protobuf.__version__\n" +) + +var ( + pythonOnce sync.Once + pythonPath string + venvDir string + setupErr error + skipReason string +) + +// Python returns an isolated interpreter with the Python runtime dependencies +// required by generated MCP bindings. It deliberately avoids global +// site-packages so Go tests are not coupled to the developer's local protobuf +// installation. +func Python(t testing.TB) string { + t.Helper() + + pythonOnce.Do(func() { + pythonPath, venvDir, setupErr = ensurePython() + if setupErr != nil && strings.HasPrefix(setupErr.Error(), "skip: ") { + skipReason = strings.TrimPrefix(setupErr.Error(), "skip: ") + setupErr = nil + } + }) + if skipReason != "" { + t.Skip(skipReason) + } + if setupErr != nil { + t.Fatalf("prepare isolated Python test runtime: %v", setupErr) + } + return pythonPath +} + +// Command creates an exec.Cmd that runs inside the isolated Python test +// environment. +func Command(t testing.TB, args ...string) *exec.Cmd { + t.Helper() + + cmd := exec.Command(Python(t), args...) + cmd.Env = Env(t) + return cmd +} + +// Env returns process environment variables for commands that should use the +// isolated Python test runtime. +func Env(t testing.TB, overrides ...string) []string { + t.Helper() + + Python(t) + binDir := filepath.Dir(pythonPath) + env := mergeEnv(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "PYTHONNOUSERSITE=1", + "PIP_DISABLE_PIP_VERSION_CHECK=1", + "PIP_NO_INPUT=1", + ) + if venvDir != "" { + env = mergeEnv(env, "VIRTUAL_ENV="+venvDir) + } + return mergeEnv(env, overrides...) +} + +func ensurePython() (string, string, error) { + if explicit := os.Getenv("PROTOC_GEN_MCP_TEST_PYTHON"); explicit != "" { + if err := probePython(explicit); err != nil { + return "", "", fmt.Errorf("PROTOC_GEN_MCP_TEST_PYTHON=%q is missing required packages: %w", explicit, err) + } + return explicit, "", nil + } + + basePython, err := findBasePython() + if err != nil { + return "", "", err + } + version, err := pythonVersion(basePython) + if err != nil { + return "", "", err + } + cacheRoot, err := pythonCacheRoot() + if err != nil { + return "", "", err + } + dir := filepath.Join(cacheRoot, "python-"+runtime.GOOS+"-"+runtime.GOARCH+"-"+version+"-"+cacheVersion) + python := venvPython(dir) + ready := filepath.Join(dir, ".ready") + + if isReady(python, ready) { + return python, dir, nil + } + if err := withSetupLock(dir+".lock", func() error { + if isReady(python, ready) { + return nil + } + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("remove stale venv: %w", err) + } + if err := run(basePython, "-m", "venv", dir); err != nil { + return fmt.Errorf("create venv: %w", err) + } + if err := run(python, "-m", "pip", "install", "--upgrade", + "mcp>=1.27,<2", + "protobuf>=6,<7", + "jsonschema>=4,<5", + "anyio>=4,<5", + ); err != nil { + return fmt.Errorf("install Python test dependencies: %w", err) + } + if err := probePython(python); err != nil { + return fmt.Errorf("probe installed Python test dependencies: %w", err) + } + return os.WriteFile(ready, []byte(time.Now().Format(time.RFC3339Nano)+"\n"), 0o644) + }); err != nil { + return "", "", err + } + return python, dir, nil +} + +func findBasePython() (string, error) { + if explicit := os.Getenv("PROTOC_GEN_MCP_BASE_PYTHON"); explicit != "" { + return explicit, nil + } + if path, err := exec.LookPath("python3"); err == nil { + return path, nil + } + if path, err := exec.LookPath("python"); err == nil { + return path, nil + } + return "", fmt.Errorf("skip: python3/python not found in PATH") +} + +func pythonVersion(python string) (string, error) { + cmd := exec.Command(python, "-c", "import sys\nassert sys.version_info >= (3, 10), sys.version\nprint(f'{sys.version_info.major}.{sys.version_info.minor}')\n") + cmd.Env = mergeEnv(os.Environ(), "PYTHONNOUSERSITE=1") + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("python %q must be 3.10 or newer: %w\n%s", python, err, output) + } + return strings.TrimSpace(string(output)), nil +} + +func pythonCacheRoot() (string, error) { + if explicit := os.Getenv("PROTOC_GEN_MCP_TEST_CACHE"); explicit != "" { + return explicit, os.MkdirAll(explicit, 0o755) + } + root, err := os.UserCacheDir() + if err != nil { + root = os.TempDir() + } + root = filepath.Join(root, "protoc-gen-mcp", "python-test") + return root, os.MkdirAll(root, 0o755) +} + +func venvPython(dir string) string { + if runtime.GOOS == "windows" { + return filepath.Join(dir, "Scripts", "python.exe") + } + return filepath.Join(dir, "bin", "python") +} + +func isReady(python, ready string) bool { + if _, err := os.Stat(ready); err != nil { + return false + } + return probePython(python) == nil +} + +func probePython(python string) error { + return run(python, "-c", probeScript) +} + +func run(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Env = mergeEnv(os.Environ(), + "PYTHONNOUSERSITE=1", + "PIP_DISABLE_PIP_VERSION_CHECK=1", + "PIP_NO_INPUT=1", + ) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, output) + } + return nil +} + +func withSetupLock(lockPath string, fn func() error) error { + deadline := time.Now().Add(5 * time.Minute) + for { + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + _ = file.Close() + defer os.Remove(lockPath) + return fn() + } + if !os.IsExist(err) { + return fmt.Errorf("create setup lock: %w", err) + } + if time.Now().After(deadline) { + return fmt.Errorf("timed out waiting for Python test runtime setup lock %s", lockPath) + } + time.Sleep(250 * time.Millisecond) + } +} + +func mergeEnv(base []string, overrides ...string) []string { + values := make(map[string]string, len(base)+len(overrides)) + order := make([]string, 0, len(base)+len(overrides)) + for _, item := range append(base, overrides...) { + key, value, ok := strings.Cut(item, "=") + if !ok { + continue + } + if _, exists := values[key]; !exists { + order = append(order, key) + } + values[key] = value + } + + merged := make([]string, 0, len(values)) + for _, key := range order { + merged = append(merged, key+"="+values[key]) + } + return merged +} diff --git a/internal/testproto/example/v1/example_mcp.py b/internal/testproto/example/v1/example_mcp.py index 2e03300..1f79aaa 100644 --- a/internal/testproto/example/v1/example_mcp.py +++ b/internal/testproto/example/v1/example_mcp.py @@ -272,6 +272,7 @@ class _RegisteredTool: handler: Any annotations: dict[str, Any] | None icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None class _ServerToolRegistry: def __init__(self, server: mcp.server.lowlevel.Server) -> None: @@ -413,6 +414,11 @@ def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | return None return mcp.types.ToolAnnotations(**raw) +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + def _tool_error_result(message: str) -> mcp.types.CallToolResult: return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=message)], @@ -436,6 +442,7 @@ def _build_tool(tool: _RegisteredTool) -> Any: outputSchema=_load_schema(tool.output_schema_json), annotations=_tool_annotations(tool.annotations), icons=tool.icons, + execution=_tool_execution(tool.execution), ) async def _maybe_await(result: Any) -> Any: @@ -1088,6 +1095,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.create_report, annotations=None, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "Health"), @@ -1102,6 +1110,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.ping, annotations=None, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "DescribeAdvancedShapes"), @@ -1116,6 +1125,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.describe_advanced_shapes, annotations=None, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "DescribeScalarShapes"), @@ -1130,6 +1140,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.describe_scalar_shapes, annotations=None, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "HiddenThing"), @@ -1144,6 +1155,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.hidden_thing, annotations=None, icons=None, + execution=None, )) EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"description\":\"City name.\",\"examples\":[\"Paris\",\"London\"],\"minLength\":1,\"maxLength\":100,\"pattern\":\"^[A-Z]\"},\"count\":{\"type\":\"integer\",\"description\":\"count is the number of requested items.\",\"default\":10,\"examples\":[-1],\"minimum\":1,\"maximum\":1000},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details contains nested report options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"labels\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"labels adds additional labels.\",\"examples\":[\"example\"]},\"description\":\"labels adds additional labels.\",\"examples\":[[\"example\"]],\"minItems\":1,\"maxItems\":50,\"uniqueItems\":true},\"units\":{\"type\":[\"string\",\"null\"],\"description\":\"units overrides the default units.\",\"examples\":[\"example\"]}},\"description\":\"CreateReportRequest describes a report generation request.\",\"examples\":[\"{\\\"city\\\":\\\"Paris\\\",\\\"count\\\":2,\\\"details\\\":{\\\"label\\\":\\\"today\\\"}}\"],\"required\":[\"city\",\"count\",\"details\"],\"additionalProperties\":false}" diff --git a/mcp/options/v1/options_mcp.py b/mcp/options/v1/options_mcp.py index 973e3ed..69e9078 100644 --- a/mcp/options/v1/options_mcp.py +++ b/mcp/options/v1/options_mcp.py @@ -190,6 +190,7 @@ class _RegisteredTool: handler: Any annotations: dict[str, Any] | None icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None class _ServerToolRegistry: def __init__(self, server: mcp.server.lowlevel.Server) -> None: @@ -331,6 +332,11 @@ def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | return None return mcp.types.ToolAnnotations(**raw) +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + def _tool_error_result(message: str) -> mcp.types.CallToolResult: return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=message)], @@ -354,6 +360,7 @@ def _build_tool(tool: _RegisteredTool) -> Any: outputSchema=_load_schema(tool.output_schema_json), annotations=_tool_annotations(tool.annotations), icons=tool.icons, + execution=_tool_execution(tool.execution), ) async def _maybe_await(result: Any) -> Any: diff --git a/testdata/golden/example_mcp.py.golden b/testdata/golden/example_mcp.py.golden index 2e03300..1f79aaa 100644 --- a/testdata/golden/example_mcp.py.golden +++ b/testdata/golden/example_mcp.py.golden @@ -272,6 +272,7 @@ class _RegisteredTool: handler: Any annotations: dict[str, Any] | None icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None class _ServerToolRegistry: def __init__(self, server: mcp.server.lowlevel.Server) -> None: @@ -413,6 +414,11 @@ def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | return None return mcp.types.ToolAnnotations(**raw) +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + def _tool_error_result(message: str) -> mcp.types.CallToolResult: return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=message)], @@ -436,6 +442,7 @@ def _build_tool(tool: _RegisteredTool) -> Any: outputSchema=_load_schema(tool.output_schema_json), annotations=_tool_annotations(tool.annotations), icons=tool.icons, + execution=_tool_execution(tool.execution), ) async def _maybe_await(result: Any) -> Any: @@ -1088,6 +1095,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.create_report, annotations=None, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "Health"), @@ -1102,6 +1110,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.ping, annotations=None, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "DescribeAdvancedShapes"), @@ -1116,6 +1125,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.describe_advanced_shapes, annotations=None, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "DescribeScalarShapes"), @@ -1130,6 +1140,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.describe_scalar_shapes, annotations=None, icons=None, + execution=None, )) registry.add_tool(_RegisteredTool( name=_tool_name(resolved_namespace, "HiddenThing"), @@ -1144,6 +1155,7 @@ def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: Example handler=impl.hidden_thing, annotations=None, icons=None, + execution=None, )) EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"description\":\"City name.\",\"examples\":[\"Paris\",\"London\"],\"minLength\":1,\"maxLength\":100,\"pattern\":\"^[A-Z]\"},\"count\":{\"type\":\"integer\",\"description\":\"count is the number of requested items.\",\"default\":10,\"examples\":[-1],\"minimum\":1,\"maximum\":1000},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details contains nested report options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"labels\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"labels adds additional labels.\",\"examples\":[\"example\"]},\"description\":\"labels adds additional labels.\",\"examples\":[[\"example\"]],\"minItems\":1,\"maxItems\":50,\"uniqueItems\":true},\"units\":{\"type\":[\"string\",\"null\"],\"description\":\"units overrides the default units.\",\"examples\":[\"example\"]}},\"description\":\"CreateReportRequest describes a report generation request.\",\"examples\":[\"{\\\"city\\\":\\\"Paris\\\",\\\"count\\\":2,\\\"details\\\":{\\\"label\\\":\\\"today\\\"}}\"],\"required\":[\"city\",\"count\",\"details\"],\"additionalProperties\":false}" From 4a270f843b9d449db2424f7f564d24d5385cd092 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sat, 2 May 2026 00:46:27 +0300 Subject: [PATCH 22/74] Address Copilot review feedback --- .../6_java_standalone/settings.gradle.kts | 2 ++ .../7_kotlin_standalone/settings.gradle.kts | 2 ++ internal/codegen/kotlin_contract_test.go | 3 ++ internal/codegen/render_kotlin.go | 34 ++++++++++++++----- internal/examplemcp/jvm_stdio_test.go | 15 ++++++++ internal/pythontest/env.go | 28 ++++++++++++++- internal/pythontest/env_test.go | 33 ++++++++++++++++++ testdata/golden/example_mcp.kt.golden | 34 ++++++++++++++----- 8 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 internal/pythontest/env_test.go diff --git a/examples/6_java_standalone/settings.gradle.kts b/examples/6_java_standalone/settings.gradle.kts index 1c080a8..ca186e9 100644 --- a/examples/6_java_standalone/settings.gradle.kts +++ b/examples/6_java_standalone/settings.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.initialization.resolve.RepositoriesMode + pluginManagement { repositories { gradlePluginPortal() diff --git a/examples/7_kotlin_standalone/settings.gradle.kts b/examples/7_kotlin_standalone/settings.gradle.kts index fa8cc89..ba9cd71 100644 --- a/examples/7_kotlin_standalone/settings.gradle.kts +++ b/examples/7_kotlin_standalone/settings.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.initialization.resolve.RepositoriesMode + pluginManagement { repositories { gradlePluginPortal() diff --git a/internal/codegen/kotlin_contract_test.go b/internal/codegen/kotlin_contract_test.go index 26e893c..bad6503 100644 --- a/internal/codegen/kotlin_contract_test.go +++ b/internal/codegen/kotlin_contract_test.go @@ -31,6 +31,9 @@ func TestKotlinContract_LowLevelServerContractShape(t *testing.T) { wantSnippets := []string{ "private class ServerToolRegistry", + "private val serverRegistries = Collections.synchronizedMap(WeakHashMap())", + "private val serverRegistryInstallLock = Any()", + "@Synchronized", "private suspend fun dispatchToolCall", "ListToolsRequest", "CallToolRequest", diff --git a/internal/codegen/render_kotlin.go b/internal/codegen/render_kotlin.go index 09edc7e..c74bafd 100644 --- a/internal/codegen/render_kotlin.go +++ b/internal/codegen/render_kotlin.go @@ -89,6 +89,7 @@ func renderKotlinFile(plugin *protogen.Plugin, model JVMFileModel) error { "kotlinx.serialization.json.jsonArray", "kotlinx.serialization.json.jsonObject", "kotlinx.serialization.json.jsonPrimitive", + "java.util.Collections", "java.util.WeakHashMap", } for importPath := range imports { @@ -227,21 +228,34 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile, descriptorAccessors generated.P("private class ServerToolRegistry {") generated.P(" private val toolsByName = linkedMapOf()") generated.P(" private val sessionsWithHandlers = mutableSetOf()") - generated.P(" var handlersInstalled: Boolean = false") + generated.P(" private var handlersInstalled: Boolean = false") generated.P() + generated.P(" @Synchronized") generated.P(" fun addTool(tool: RegisteredTool) {") generated.P(" require(!toolsByName.containsKey(tool.name)) { \"duplicate tool registration: ${tool.name}\" }") generated.P(" toolsByName[tool.name] = tool") generated.P(" }") generated.P() + generated.P(" @Synchronized") generated.P(" fun allTools(): List = toolsByName.values.toList()") generated.P() + generated.P(" @Synchronized") generated.P(" fun tool(name: String): RegisteredTool? = toolsByName[name]") generated.P() + generated.P(" @Synchronized") generated.P(" fun markSessionInstalled(sessionId: String): Boolean = sessionsWithHandlers.add(sessionId)") + generated.P() + generated.P(" @Synchronized") + generated.P(" fun hasHandlersInstalled(): Boolean = handlersInstalled") + generated.P() + generated.P(" @Synchronized") + generated.P(" fun markHandlersInstalled() {") + generated.P(" handlersInstalled = true") + generated.P(" }") generated.P("}") generated.P() - generated.P("private val serverRegistries = WeakHashMap()") + generated.P("private val serverRegistries = Collections.synchronizedMap(WeakHashMap())") + generated.P("private val serverRegistryInstallLock = Any()") generated.P("private val jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)") generated.P("private val protoTypeRegistry = buildTypeRegistry()") generated.P() @@ -278,15 +292,17 @@ func renderKotlinRuntime(generated *protogen.GeneratedFile, descriptorAccessors generated.P("}") generated.P() generated.P("private fun installMcpHandlers(server: Server): ServerToolRegistry {") - generated.P(" val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() }") - generated.P(" if (registry.handlersInstalled) {") + generated.P(" synchronized(serverRegistryInstallLock) {") + generated.P(" val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() }") + generated.P(" if (registry.hasHandlersInstalled()) {") + generated.P(" return registry") + generated.P(" }") + generated.P(" require(server.tools.isEmpty()) { \"protoc-gen-mcp generated handlers cannot compose with pre-existing SDK tool registrations\" }") + generated.P(" installSessionHandlers(server, registry)") + generated.P(" server.onConnect { installSessionHandlers(server, registry) }") + generated.P(" registry.markHandlersInstalled()") generated.P(" return registry") generated.P(" }") - generated.P(" require(server.tools.isEmpty()) { \"protoc-gen-mcp generated handlers cannot compose with pre-existing SDK tool registrations\" }") - generated.P(" installSessionHandlers(server, registry)") - generated.P(" server.onConnect { installSessionHandlers(server, registry) }") - generated.P(" registry.handlersInstalled = true") - generated.P(" return registry") generated.P("}") generated.P() generated.P("private fun installSessionHandlers(server: Server, registry: ServerToolRegistry) {") diff --git a/internal/examplemcp/jvm_stdio_test.go b/internal/examplemcp/jvm_stdio_test.go index 1e24549..2301e62 100644 --- a/internal/examplemcp/jvm_stdio_test.go +++ b/internal/examplemcp/jvm_stdio_test.go @@ -16,6 +16,7 @@ import ( var ( ensureJVMExamplesInstalledOnce sync.Once ensureJVMExamplesInstalledErr error + ensureJVMExamplesInstalledSkip string ) func TestJavaServerOverStdio(t *testing.T) { @@ -57,6 +58,17 @@ func ensureJVMExamplesInstalled(t *testing.T, root string) { javaScript := filepath.Join(root, "examples/jvm/java-server/build/install/java-server/bin/java-server") kotlinScript := filepath.Join(root, "examples/jvm/kotlin-server/build/install/kotlin-server/bin/kotlin-server") if fileExists(javaScript) && fileExists(kotlinScript) { + if _, err := exec.LookPath("java"); err != nil { + ensureJVMExamplesInstalledSkip = "java not found in PATH; skipping JVM stdio tests" + } + return + } + if _, err := exec.LookPath("gradle"); err != nil { + ensureJVMExamplesInstalledSkip = "gradle not found in PATH; skipping JVM stdio tests" + return + } + if _, err := exec.LookPath("javac"); err != nil { + ensureJVMExamplesInstalledSkip = "javac not found in PATH; skipping JVM stdio tests" return } @@ -74,6 +86,9 @@ func ensureJVMExamplesInstalled(t *testing.T, root string) { } }) + if ensureJVMExamplesInstalledSkip != "" { + t.Skip(ensureJVMExamplesInstalledSkip) + } if ensureJVMExamplesInstalledErr != nil { t.Fatal(ensureJVMExamplesInstalledErr) } diff --git a/internal/pythontest/env.go b/internal/pythontest/env.go index 4d6d09b..df26628 100644 --- a/internal/pythontest/env.go +++ b/internal/pythontest/env.go @@ -15,6 +15,9 @@ import ( const ( cacheVersion = "v1" probeScript = "import anyio, jsonschema, mcp\nimport google.protobuf\nmajor = int(google.protobuf.__version__.split('.', 1)[0])\nassert 6 <= major < 7, google.protobuf.__version__\n" + + setupLockTimeout = 5 * time.Minute + staleSetupLock = 10 * time.Minute ) var ( @@ -200,10 +203,11 @@ func run(name string, args ...string) error { } func withSetupLock(lockPath string, fn func() error) error { - deadline := time.Now().Add(5 * time.Minute) + deadline := time.Now().Add(setupLockTimeout) for { file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err == nil { + _, _ = fmt.Fprintf(file, "pid=%d created=%s\n", os.Getpid(), time.Now().Format(time.RFC3339Nano)) _ = file.Close() defer os.Remove(lockPath) return fn() @@ -211,6 +215,11 @@ func withSetupLock(lockPath string, fn func() error) error { if !os.IsExist(err) { return fmt.Errorf("create setup lock: %w", err) } + if removed, err := removeStaleSetupLock(lockPath); err != nil { + return err + } else if removed { + continue + } if time.Now().After(deadline) { return fmt.Errorf("timed out waiting for Python test runtime setup lock %s", lockPath) } @@ -218,6 +227,23 @@ func withSetupLock(lockPath string, fn func() error) error { } } +func removeStaleSetupLock(lockPath string) (bool, error) { + info, err := os.Stat(lockPath) + if err != nil { + if os.IsNotExist(err) { + return true, nil + } + return false, fmt.Errorf("stat setup lock: %w", err) + } + if time.Since(info.ModTime()) <= staleSetupLock { + return false, nil + } + if err := os.Remove(lockPath); err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("remove stale setup lock: %w", err) + } + return true, nil +} + func mergeEnv(base []string, overrides ...string) []string { values := make(map[string]string, len(base)+len(overrides)) order := make([]string, 0, len(base)+len(overrides)) diff --git a/internal/pythontest/env_test.go b/internal/pythontest/env_test.go new file mode 100644 index 0000000..21f8d19 --- /dev/null +++ b/internal/pythontest/env_test.go @@ -0,0 +1,33 @@ +package pythontest + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestWithSetupLockRemovesStaleLock(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "python.lock") + if err := os.WriteFile(lockPath, []byte("stale\n"), 0o644); err != nil { + t.Fatalf("write stale lock: %v", err) + } + staleTime := time.Now().Add(-staleSetupLock - time.Minute) + if err := os.Chtimes(lockPath, staleTime, staleTime); err != nil { + t.Fatalf("age stale lock: %v", err) + } + + called := false + if err := withSetupLock(lockPath, func() error { + called = true + return nil + }); err != nil { + t.Fatalf("withSetupLock: %v", err) + } + if !called { + t.Fatal("withSetupLock did not call callback") + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Fatalf("lock still exists after callback: %v", err) + } +} diff --git a/testdata/golden/example_mcp.kt.golden b/testdata/golden/example_mcp.kt.golden index 75259cc..68dd0bf 100644 --- a/testdata/golden/example_mcp.kt.golden +++ b/testdata/golden/example_mcp.kt.golden @@ -25,6 +25,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations import io.modelcontextprotocol.kotlin.sdk.types.ToolExecution import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import java.util.Collections import java.util.WeakHashMap import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -50,21 +51,34 @@ private data class RegisteredTool( private class ServerToolRegistry { private val toolsByName = linkedMapOf() private val sessionsWithHandlers = mutableSetOf() - var handlersInstalled: Boolean = false + private var handlersInstalled: Boolean = false + @Synchronized fun addTool(tool: RegisteredTool) { require(!toolsByName.containsKey(tool.name)) { "duplicate tool registration: ${tool.name}" } toolsByName[tool.name] = tool } + @Synchronized fun allTools(): List = toolsByName.values.toList() + @Synchronized fun tool(name: String): RegisteredTool? = toolsByName[name] + @Synchronized fun markSessionInstalled(sessionId: String): Boolean = sessionsWithHandlers.add(sessionId) + + @Synchronized + fun hasHandlersInstalled(): Boolean = handlersInstalled + + @Synchronized + fun markHandlersInstalled() { + handlersInstalled = true + } } -private val serverRegistries = WeakHashMap() +private val serverRegistries = Collections.synchronizedMap(WeakHashMap()) +private val serverRegistryInstallLock = Any() private val jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) private val protoTypeRegistry = buildTypeRegistry() @@ -99,15 +113,17 @@ private fun registerMessageType( } private fun installMcpHandlers(server: Server): ServerToolRegistry { - val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() } - if (registry.handlersInstalled) { + synchronized(serverRegistryInstallLock) { + val registry = serverRegistries.getOrPut(server) { ServerToolRegistry() } + if (registry.hasHandlersInstalled()) { + return registry + } + require(server.tools.isEmpty()) { "protoc-gen-mcp generated handlers cannot compose with pre-existing SDK tool registrations" } + installSessionHandlers(server, registry) + server.onConnect { installSessionHandlers(server, registry) } + registry.markHandlersInstalled() return registry } - require(server.tools.isEmpty()) { "protoc-gen-mcp generated handlers cannot compose with pre-existing SDK tool registrations" } - installSessionHandlers(server, registry) - server.onConnect { installSessionHandlers(server, registry) } - registry.handlersInstalled = true - return registry } private fun installSessionHandlers(server: Server, registry: ServerToolRegistry) { From 52a6d864e2b4cd9fabb828837e88bf5c7afe0075 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sun, 3 May 2026 23:36:00 +0300 Subject: [PATCH 23/74] feat(07-01): add typescript target option plumbing - accept lang=typescript in protoc-gen-mcp options - synthesize protogen go_package metadata for TypeScript-only protos --- internal/codegen/options.go | 11 ++++++----- internal/codegen/options_test.go | 9 +++++++++ internal/codegen/request.go | 4 ++-- internal/codegen/request_test.go | 6 +++--- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/codegen/options.go b/internal/codegen/options.go index 1b6da9c..de02173 100644 --- a/internal/codegen/options.go +++ b/internal/codegen/options.go @@ -8,10 +8,11 @@ import ( type Language string const ( - LanguageGo Language = "go" - LanguagePython Language = "python" - LanguageKotlin Language = "kotlin" - LanguageJava Language = "java" + LanguageGo Language = "go" + LanguagePython Language = "python" + LanguageKotlin Language = "kotlin" + LanguageJava Language = "java" + LanguageTypeScript Language = "typescript" ) type PythonRuntime string @@ -64,7 +65,7 @@ func (p *OptionsParser) Options() (Options, error) { if p.sawPythonRuntime { return Options{}, fmt.Errorf("python_runtime is only supported when lang=python") } - case LanguageKotlin, LanguageJava: + case LanguageKotlin, LanguageJava, LanguageTypeScript: if p.sawPythonRuntime { return Options{}, fmt.Errorf("python_runtime is only supported when lang=python") } diff --git a/internal/codegen/options_test.go b/internal/codegen/options_test.go index aa6d25f..a6bf5f0 100644 --- a/internal/codegen/options_test.go +++ b/internal/codegen/options_test.go @@ -32,6 +32,11 @@ func TestParseOptions_JVMLanguages(t *testing.T) { raw: "lang=java", want: LanguageJava, }, + { + name: "typescript", + raw: "lang=typescript", + want: LanguageTypeScript, + }, } for _, tt := range tests { @@ -108,6 +113,10 @@ func TestParseOptions_PythonRuntimeRejectedForNonPythonLanguages(t *testing.T) { name: "java", raw: "lang=java,python_runtime=google.protobuf", }, + { + name: "typescript", + raw: "lang=typescript,python_runtime=google.protobuf", + }, } for _, tt := range tests { diff --git a/internal/codegen/request.go b/internal/codegen/request.go index b271793..2ecc766 100644 --- a/internal/codegen/request.go +++ b/internal/codegen/request.go @@ -12,7 +12,7 @@ import ( const syntheticGoPackageName = "mcpgenerated" // PrepareRequestForProtogen adjusts the raw protoc request before protogen -// validates Go-specific metadata. Python and JVM generation do not use Go +// validates Go-specific metadata. Python, JVM, and TypeScript generation do not use Go // import paths, but protogen still requires them to build its descriptor model. func PrepareRequestForProtogen(req *pluginpb.CodeGeneratorRequest, opts Options) { if req == nil || !requiresSyntheticGoPackage(opts.Language) { @@ -32,7 +32,7 @@ func PrepareRequestForProtogen(req *pluginpb.CodeGeneratorRequest, opts Options) func requiresSyntheticGoPackage(language Language) bool { switch language { - case LanguagePython, LanguageKotlin, LanguageJava: + case LanguagePython, LanguageKotlin, LanguageJava, LanguageTypeScript: return true default: return false diff --git a/internal/codegen/request_test.go b/internal/codegen/request_test.go index 726226e..5208a7a 100644 --- a/internal/codegen/request_test.go +++ b/internal/codegen/request_test.go @@ -31,8 +31,8 @@ func TestPrepareRequestForProtogen_SynthesizesGoPackageForPython(t *testing.T) { } } -func TestPrepareRequestForProtogen_SynthesizesGoPackageForJVM(t *testing.T) { - for _, language := range []Language{LanguageKotlin, LanguageJava} { +func TestPrepareRequestForProtogen_SynthesizesGoPackageForNonGoTargets(t *testing.T) { + for _, language := range []Language{LanguageKotlin, LanguageJava, LanguageTypeScript} { t.Run(string(language), func(t *testing.T) { req := &pluginpb.CodeGeneratorRequest{ ProtoFile: []*descriptorpb.FileDescriptorProto{ @@ -96,7 +96,7 @@ func TestPrepareRequestForProtogen_PreservesExplicitGoPackage(t *testing.T) { } func TestPrepareRequestForProtogen_AllowsNonGoProtogenNewWithoutGoPackage(t *testing.T) { - for _, language := range []Language{LanguagePython, LanguageKotlin, LanguageJava} { + for _, language := range []Language{LanguagePython, LanguageKotlin, LanguageJava, LanguageTypeScript} { t.Run(string(language), func(t *testing.T) { req := &pluginpb.CodeGeneratorRequest{ Parameter: proto.String("paths=source_relative,lang=" + string(language)), From 27d4bcd718d2adcdba3de1ad02d76e643e837e60 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sun, 3 May 2026 23:36:10 +0300 Subject: [PATCH 24/74] feat(07-01): emit typescript sidecar foundation - dispatch lang=typescript through shared FileModel collection - emit deterministic TypeScript schema JSON constants - add TypeScript golden and fail-fast coverage --- internal/codegen/generator.go | 36 +++++++++ internal/codegen/generator_test.go | 108 ++++++++++++++++++++++++++ internal/codegen/render_typescript.go | 61 +++++++++++++++ testdata/golden/example_mcp.ts.golden | 17 ++++ 4 files changed, 222 insertions(+) create mode 100644 internal/codegen/render_typescript.go create mode 100644 testdata/golden/example_mcp.ts.golden diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 40113a1..1ae1ad5 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -14,6 +14,7 @@ func Generate(plugin *protogen.Plugin, opts Options) error { case LanguagePython: case LanguageKotlin: case LanguageJava: + case LanguageTypeScript: default: return fmt.Errorf("unsupported lang %q", opts.Language) } @@ -71,6 +72,21 @@ func Generate(plugin *protogen.Plugin, opts Options) error { } } return nil + case LanguageTypeScript: + models, orderedFiles, err := collectTypeScriptModels(plugin, opts) + if err != nil { + return err + } + for _, file := range orderedFiles { + model := models[file.Desc.Path()] + if len(model.Services) == 0 { + continue + } + if err := renderTypeScriptFile(plugin, model); err != nil { + return err + } + } + return nil } for _, file := range plugin.Files { @@ -105,6 +121,26 @@ func Generate(plugin *protogen.Plugin, opts Options) error { return nil } +func collectTypeScriptModels(plugin *protogen.Plugin, opts Options) (map[string]FileModel, []*protogen.File, error) { + models := make(map[string]FileModel) + orderedFiles := make([]*protogen.File, 0, len(plugin.Files)) + + for _, file := range plugin.Files { + if !file.Generate { + continue + } + + model, err := CollectFileModel(file, opts) + if err != nil { + return nil, nil, err + } + models[file.Desc.Path()] = model + orderedFiles = append(orderedFiles, file) + } + + return models, orderedFiles, nil +} + func collectKotlinModels(plugin *protogen.Plugin, opts Options) (map[string]JVMFileModel, []*protogen.File, error) { models := make(map[string]JVMFileModel) orderedFiles := make([]*protogen.File, 0, len(plugin.Files)) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index cc6363f..7f80c40 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -67,6 +67,19 @@ func TestGenerateJavaExampleGolden(t *testing.T) { } } +func TestGenerateTypeScriptExampleGolden(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate: %v", err) + } + + got := generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp.ts") + want := readExampleTypeScriptGolden(t, repoRoot(t)) + if !bytes.Equal(got, want) { + t.Fatalf("fresh TypeScript renderer output differs from golden:\n%s", diffBytes(want, got)) + } +} + func TestGenerate_KotlinTargetEmitsOutput(t *testing.T) { plugin := newExampleProtogenPlugin(t) if err := Generate(plugin, Options{Language: LanguageKotlin}); err != nil { @@ -122,6 +135,33 @@ func TestGenerate_JavaTargetEmitsOutput(t *testing.T) { } } +func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate: %v", err) + } + + generated := string(generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp.ts")) + wantSnippets := []string{ + "// Code generated by protoc-gen-mcp. DO NOT EDIT.", + "// source: internal/testproto/example/v1/example.proto", + "export const EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON", + "export const EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON", + "export const EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON", + "export const EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON", + } + for _, snippet := range wantSnippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated TypeScript missing snippet %q\n%s", snippet, generated) + } + } + for _, snippet := range []string{"registerTool", "zod", "lang=javascript"} { + if strings.Contains(generated, snippet) { + t.Fatalf("generated TypeScript foundation must not contain snippet %q\n%s", snippet, generated) + } + } +} + func TestGenerate_PythonEmitsMCPNamespaceBridge(t *testing.T) { plugin := newExampleProtogenPlugin(t) if err := Generate(plugin, Options{ @@ -518,6 +558,36 @@ func TestGenerate_JVMUnsupportedDescriptorFailsBeforeOutput(t *testing.T) { } } +func TestGenerate_TypeScriptUnsupportedDescriptorFailsBeforeOutput(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/unsupported.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/unsupported;unsupportedv1";`, + `import "google/protobuf/type.proto";`, + `message BuildUnsupportedRequest {`, + ` google.protobuf.Type payload = 1;`, + `}`, + `message BuildUnsupportedResponse {}`, + `service UnsupportedAPI {`, + ` rpc BuildUnsupported(BuildUnsupportedRequest) returns (BuildUnsupportedResponse);`, + `}`, + "", + }, "\n"), + }, "test/v1/unsupported.proto") + + err := Generate(plugin, Options{Language: LanguageTypeScript}) + if err == nil { + t.Fatal("Generate unexpectedly succeeded") + } + if !strings.Contains(err.Error(), `well-known type "google.protobuf.Type" is not supported`) { + t.Fatalf("unexpected generator failure: %v", err) + } + if len(plugin.Response().GetFile()) != 0 { + t.Fatalf("TypeScript unsupported descriptor emitted %d files, want 0", len(plugin.Response().GetFile())) + } +} + func TestGenerate_PythonUnsupportedRuntimeBetterprotoFails(t *testing.T) { root := repoRoot(t) @@ -648,6 +718,33 @@ func TestGenerate_JVMStreamingRPCFailsBeforeOutput(t *testing.T) { } } +func TestGenerate_TypeScriptStreamingRPCFailsBeforeOutput(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/streaming.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/streaming;streamingv1";`, + `message StreamingRequest {}`, + `message StreamingResponse {}`, + `service StreamingAPI {`, + ` rpc Watch(StreamingRequest) returns (stream StreamingResponse);`, + `}`, + "", + }, "\n"), + }, "test/v1/streaming.proto") + + err := Generate(plugin, Options{Language: LanguageTypeScript}) + if err == nil { + t.Fatal("Generate unexpectedly succeeded") + } + if !strings.Contains(err.Error(), `streaming RPC is not supported`) { + t.Fatalf("unexpected generator failure: %v", err) + } + if len(plugin.Response().GetFile()) != 0 { + t.Fatalf("TypeScript streaming RPC emitted %d files, want 0", len(plugin.Response().GetFile())) + } +} + func TestGenerateJavaExampleHandlerCompileSmoke(t *testing.T) { if _, err := exec.LookPath("javac"); err != nil { t.Skipf("javac not found in PATH: %v", err) @@ -808,6 +905,17 @@ func readExampleJavaGolden(t *testing.T, root string) []byte { return want } +func readExampleTypeScriptGolden(t *testing.T, root string) []byte { + t.Helper() + + wantPath := filepath.Join(root, "testdata/golden/example_mcp.ts.golden") + want, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("read golden file %q: %v", wantPath, err) + } + return want +} + func generatedFileContent(t *testing.T, plugin *protogen.Plugin, path string) []byte { t.Helper() diff --git a/internal/codegen/render_typescript.go b/internal/codegen/render_typescript.go new file mode 100644 index 0000000..c841487 --- /dev/null +++ b/internal/codegen/render_typescript.go @@ -0,0 +1,61 @@ +package codegen + +import ( + "encoding/json" + "fmt" + "path" + "strings" + + "google.golang.org/protobuf/compiler/protogen" +) + +func renderTypeScriptFile(plugin *protogen.Plugin, model FileModel) error { + if model.Options.Language != LanguageTypeScript { + return fmt.Errorf("typescript renderer requires lang=typescript, got %q", model.Options.Language) + } + + filename := typescriptOutputPathForProtoPath(model.ProtoPath) + generated := plugin.NewGeneratedFile(filename, "") + + generated.P("// Code generated by protoc-gen-mcp. DO NOT EDIT.") + generated.P("// source: ", model.ProtoPath) + generated.P() + + for serviceIndex, service := range model.Services { + for methodIndex, method := range service.Methods { + if serviceIndex > 0 || methodIndex > 0 { + generated.P() + } + schemaConst := typescriptSchemaConst(service.ProtoName, method.ProtoName) + generated.P("export const ", schemaConst, "_INPUT_SCHEMA_JSON = ", typescriptStringLiteral(method.InputSchemaJSON), ";") + generated.P("export const ", schemaConst, "_OUTPUT_SCHEMA_JSON = ", typescriptStringLiteral(method.OutputSchemaJSON), ";") + } + } + + return nil +} + +func typescriptOutputPathForProtoPath(protoPath string) string { + return typescriptGeneratedFilenamePrefixForProtoPath(protoPath) + "_mcp.ts" +} + +func typescriptGeneratedFilenamePrefixForProtoPath(protoPath string) string { + prefix := strings.TrimSuffix(protoPath, path.Ext(protoPath)) + parts := strings.Split(prefix, "/") + for i, part := range parts { + parts[i] = strings.ReplaceAll(part, "-", "_") + } + return strings.Join(parts, "/") +} + +func typescriptSchemaConst(service, method string) string { + return toUpperSnakeCase(service) + "_" + toUpperSnakeCase(method) +} + +func typescriptStringLiteral(value string) string { + encoded, err := json.Marshal(value) + if err != nil { + panic(fmt.Sprintf("marshal TypeScript string literal: %v", err)) + } + return string(encoded) +} diff --git a/testdata/golden/example_mcp.ts.golden b/testdata/golden/example_mcp.ts.golden new file mode 100644 index 0000000..eb70ed6 --- /dev/null +++ b/testdata/golden/example_mcp.ts.golden @@ -0,0 +1,17 @@ +// Code generated by protoc-gen-mcp. DO NOT EDIT. +// source: internal/testproto/example/v1/example.proto + +export const EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"description\":\"City name.\",\"examples\":[\"Paris\",\"London\"],\"minLength\":1,\"maxLength\":100,\"pattern\":\"^[A-Z]\"},\"count\":{\"type\":\"integer\",\"description\":\"count is the number of requested items.\",\"default\":10,\"examples\":[-1],\"minimum\":1,\"maximum\":1000},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details contains nested report options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"labels\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"labels adds additional labels.\",\"examples\":[\"example\"]},\"description\":\"labels adds additional labels.\",\"examples\":[[\"example\"]],\"minItems\":1,\"maxItems\":50,\"uniqueItems\":true},\"units\":{\"type\":[\"string\",\"null\"],\"description\":\"units overrides the default units.\",\"examples\":[\"example\"]}},\"description\":\"CreateReportRequest describes a report generation request.\",\"examples\":[\"{\\\"city\\\":\\\"Paris\\\",\\\"count\\\":2,\\\"details\\\":{\\\"label\\\":\\\"today\\\"}}\"],\"required\":[\"city\",\"count\",\"details\"],\"additionalProperties\":false}"; +export const EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details echoes the nested options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"reportId\":{\"type\":\"string\",\"description\":\"report_id is the generated report identifier.\",\"examples\":[\"example\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status is the final report status.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"totalCount\":{\"type\":\"string\",\"description\":\"total_count is returned as a ProtoJSON string.\",\"examples\":[\"-1\"]},\"warnings\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"warnings contains optional warning messages.\",\"examples\":[\"example\"]},\"description\":\"warnings contains optional warning messages.\",\"examples\":[[\"example\"]]}},\"description\":\"CreateReportResponse describes a report generation result.\",\"examples\":[{\"details\":{\"label\":\"example\"},\"reportId\":\"example\",\"status\":\"REPORT_STATUS_OK\",\"totalCount\":\"-1\",\"warnings\":[\"example\"]}],\"required\":[\"reportId\",\"totalCount\",\"status\",\"details\"],\"additionalProperties\":false}"; + +export const EXAMPLE_API_PING_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"description\":\"PingRequest is used by the health-check RPC.\",\"additionalProperties\":false}"; +export const EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"ack\":{\"type\":\"object\",\"description\":\"ack confirms the health-check call.\",\"examples\":[{}],\"additionalProperties\":false}},\"description\":\"PingResponse is used by the health-check RPC.\",\"examples\":[{\"ack\":{}}],\"required\":[\"ack\"],\"additionalProperties\":false}"; + +export const EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesRequest exercises supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}"; +export const EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"blob\":{\"type\":[\"string\",\"null\"],\"description\":\"blob exercises BytesValue wrapper support.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"cityAlias\":{\"type\":[\"string\",\"null\"],\"description\":\"city_alias exercises string oneof members.\",\"examples\":[\"example\"]},\"cityDetails\":{\"type\":[\"object\",\"null\"],\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"city_details exercises message oneof members.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"cityId\":{\"type\":[\"string\",\"null\"],\"description\":\"city_id exercises int64 oneof members with ProtoJSON string encoding.\",\"examples\":[\"-1\"]},\"detailAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"detail_any exercises Any with a regular embedded message payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"durationAny\":{\"type\":[\"object\",\"null\"],\"properties\":{\"@type\":{\"type\":\"string\",\"description\":\"Type URL of the embedded protobuf message, usually in the form type.googleapis.com/\\u003cfull.message.name\\u003e.\"}},\"description\":\"duration_any exercises Any with a well-known embedded payload.\\n\\nProtoJSON representation of google.protobuf.Any. Provide @type with the embedded message type URL and include the embedded message JSON fields alongside it. For well-known types that use a custom JSON form, send @type plus a value field containing that custom representation.\",\"examples\":[{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"}],\"required\":[\"@type\"],\"additionalProperties\":true},\"dynamic\":{\"description\":\"dynamic accepts arbitrary JSON values.\",\"examples\":[{\"kind\":\"demo\"}]},\"enabled\":{\"type\":[\"boolean\",\"null\"],\"description\":\"enabled exercises BoolValue wrapper support.\",\"examples\":[true]},\"hugeTotal\":{\"type\":[\"string\",\"null\"],\"description\":\"huge_total exercises UInt64Value wrapper support.\",\"examples\":[\"1\"]},\"items\":{\"type\":[\"array\",\"null\"],\"items\":true,\"description\":\"items accepts arbitrary JSON arrays.\",\"examples\":[[\"item\",2,true]]},\"labels\":{\"type\":[\"object\",\"null\"],\"description\":\"labels stores arbitrary string metadata.\",\"examples\":[{\"key\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\"}},\"limits\":{\"type\":[\"object\",\"null\"],\"description\":\"limits demonstrates unsigned numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^(0|[1-9][0-9]*)$\"}},\"mask\":{\"type\":[\"string\",\"null\"],\"description\":\"mask exercises FieldMask string support.\",\"examples\":[\"fieldName,otherField\"]},\"note\":{\"type\":[\"string\",\"null\"],\"description\":\"note exercises `StringValue` wrapper support.\",\"examples\":[\"example\"]},\"observedAt\":{\"type\":[\"string\",\"null\"],\"description\":\"observed_at is represented as an RFC 3339 timestamp string.\",\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"payload\":{\"type\":[\"object\",\"null\"],\"description\":\"payload accepts arbitrary JSON objects.\",\"examples\":[{\"kind\":\"demo\",\"nested\":{\"ok\":true}}],\"additionalProperties\":true},\"quantities\":{\"type\":[\"object\",\"null\"],\"description\":\"quantities demonstrates numeric map keys encoded as JSON object keys.\",\"examples\":[{\"1\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"pattern\":\"^-?(0|[1-9][0-9]*)$\"}},\"ratio\":{\"description\":\"ratio exercises ProtoJSON float special values.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"rawRatio\":{\"description\":\"raw_ratio exercises raw double ProtoJSON float support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"smallTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"small_total exercises Int32Value wrapper support.\",\"examples\":[1]},\"toggles\":{\"type\":[\"object\",\"null\"],\"description\":\"toggles demonstrates bool map keys encoded as JSON object keys.\",\"examples\":[{\"true\":\"example\"}],\"additionalProperties\":{\"type\":\"string\"},\"propertyNames\":{\"type\":\"string\",\"enum\":[\"true\",\"false\"]}},\"total\":{\"type\":[\"string\",\"null\"],\"description\":\"total exercises Int64Value wrapper support.\",\"examples\":[\"1\"]},\"tree\":{\"type\":[\"object\",\"null\"],\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"tree exercises recursive message schemas.\\n\\nRecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false},\"ttl\":{\"type\":[\"string\",\"null\"],\"description\":\"ttl is represented as a protobuf duration string.\",\"examples\":[\"3600s\"],\"pattern\":\"^-?[0-9]+(?:\\\\.[0-9]{1,9})?s$\"},\"uintTotal\":{\"type\":[\"integer\",\"null\"],\"description\":\"uint_total exercises UInt32Value wrapper support.\",\"examples\":[1]},\"weight\":{\"description\":\"weight exercises FloatValue wrapper support.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]}},\"$defs\":{\"internal.testproto.example.v1.RecursiveNode\":{\"type\":\"object\",\"properties\":{\"child\":{\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"anyOf\":[{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"child points at the next recursive node.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},{\"type\":\"null\"}]},\"children\":{\"type\":[\"array\",\"null\"],\"items\":{\"$ref\":\"#/$defs/internal.testproto.example.v1.RecursiveNode\",\"description\":\"children holds repeated recursive nodes.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]},\"description\":\"children holds repeated recursive nodes.\",\"examples\":[[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}]]},\"name\":{\"type\":\"string\",\"description\":\"name identifies the current node.\",\"examples\":[\"example\"]}},\"description\":\"RecursiveNode exercises recursive message schemas.\",\"examples\":[{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}},\"description\":\"DescribeAdvancedShapesResponse echoes supported advanced protobuf shapes.\",\"examples\":[{\"blob\":\"aGVsbG8=\",\"cityAlias\":\"example\",\"detailAny\":{\"@type\":\"type.googleapis.com/internal.testproto.example.v1.ReportDetails\",\"label\":\"from-any\"},\"durationAny\":{\"@type\":\"type.googleapis.com/google.protobuf.Duration\",\"value\":\"3600s\"},\"dynamic\":{\"kind\":\"demo\"},\"enabled\":true,\"hugeTotal\":\"1\",\"items\":[\"item\",2,true],\"labels\":{\"key\":\"example\"},\"limits\":{\"1\":\"example\"},\"mask\":\"fieldName,otherField\",\"note\":\"example\",\"observedAt\":\"2026-03-09T10:11:12Z\",\"payload\":{\"kind\":\"demo\",\"nested\":{\"ok\":true}},\"quantities\":{\"1\":\"example\"},\"ratio\":1.25,\"rawRatio\":1.25,\"smallTotal\":1,\"toggles\":{\"true\":\"example\"},\"total\":\"1\",\"tree\":{\"child\":{\"name\":\"example\"},\"children\":[{\"name\":\"example\"}],\"name\":\"example\"},\"ttl\":\"3600s\",\"uintTotal\":1,\"weight\":1.25}],\"additionalProperties\":false,\"allOf\":[{\"oneOf\":[{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}},{\"allOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}}]}}]},{\"allOf\":[{\"required\":[\"cityDetails\"],\"not\":{\"properties\":{\"cityDetails\":{\"type\":\"null\"}},\"required\":[\"cityDetails\"]}},{\"not\":{\"anyOf\":[{\"required\":[\"cityAlias\"],\"not\":{\"properties\":{\"cityAlias\":{\"type\":\"null\"}},\"required\":[\"cityAlias\"]}},{\"required\":[\"cityId\"],\"not\":{\"properties\":{\"cityId\":{\"type\":\"null\"}},\"required\":[\"cityId\"]}}]}}]}]}]}"; + +export const EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"boolFlag\":{\"type\":\"boolean\",\"description\":\"bool_flag exercises the plain bool kind.\",\"examples\":[true]},\"bytesValue\":{\"type\":\"string\",\"description\":\"bytes_value exercises the plain bytes kind.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details exercises plain nested message handling.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"doubleValue\":{\"description\":\"double_value exercises the plain double kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"fixed32Value\":{\"type\":\"integer\",\"description\":\"fixed32_value exercises the plain fixed32 kind.\",\"examples\":[1]},\"fixed64Value\":{\"type\":\"string\",\"description\":\"fixed64_value exercises the plain fixed64 kind.\",\"examples\":[\"1\"]},\"floatValue\":{\"description\":\"float_value exercises the plain float kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"int32Value\":{\"type\":\"integer\",\"description\":\"int32_value exercises the plain int32 kind.\",\"examples\":[-1]},\"int64Value\":{\"type\":\"string\",\"description\":\"int64_value exercises the plain int64 kind.\",\"examples\":[\"-1\"]},\"optionalBoolFlag\":{\"type\":[\"boolean\",\"null\"],\"description\":\"optional_bool_flag exercises proto3 optional bool.\",\"examples\":[true]},\"optionalBytesValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_bytes_value exercises proto3 optional bytes.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"optionalDoubleValue\":{\"description\":\"optional_double_value exercises proto3 optional double.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalFixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_fixed32_value exercises proto3 optional fixed32.\",\"examples\":[1]},\"optionalFixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_fixed64_value exercises proto3 optional fixed64.\",\"examples\":[\"1\"]},\"optionalFloatValue\":{\"description\":\"optional_float_value exercises proto3 optional float.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalInt32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_int32_value exercises proto3 optional int32.\",\"examples\":[-1]},\"optionalInt64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_int64_value exercises proto3 optional int64.\",\"examples\":[\"-1\"]},\"optionalSfixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sfixed32_value exercises proto3 optional sfixed32.\",\"examples\":[-1]},\"optionalSfixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sfixed64_value exercises proto3 optional sfixed64.\",\"examples\":[\"-1\"]},\"optionalSint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sint32_value exercises proto3 optional sint32.\",\"examples\":[-1]},\"optionalSint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sint64_value exercises proto3 optional sint64.\",\"examples\":[\"-1\"]},\"optionalStatus\":{\"description\":\"optional_status exercises proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"anyOf\":[{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"optional_status exercises proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},{\"type\":\"null\"}]},\"optionalTextValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_text_value exercises proto3 optional string.\",\"examples\":[\"example\"]},\"optionalUint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_uint32_value exercises proto3 optional uint32.\",\"examples\":[1]},\"optionalUint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_uint64_value exercises proto3 optional uint64.\",\"examples\":[\"1\"]},\"samples\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"integer\",\"description\":\"samples exercises repeated plain scalar handling.\",\"examples\":[-1]},\"description\":\"samples exercises repeated plain scalar handling.\",\"examples\":[[-1]]},\"sfixed32Value\":{\"type\":\"integer\",\"description\":\"sfixed32_value exercises the plain sfixed32 kind.\",\"examples\":[-1]},\"sfixed64Value\":{\"type\":\"string\",\"description\":\"sfixed64_value exercises the plain sfixed64 kind.\",\"examples\":[\"-1\"]},\"sint32Value\":{\"type\":\"integer\",\"description\":\"sint32_value exercises the plain sint32 kind.\",\"examples\":[-1]},\"sint64Value\":{\"type\":\"string\",\"description\":\"sint64_value exercises the plain sint64 kind.\",\"examples\":[\"-1\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status exercises plain enum handling.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"textValue\":{\"type\":\"string\",\"description\":\"text_value exercises the plain string kind.\",\"examples\":[\"example\"]},\"uint32Value\":{\"type\":\"integer\",\"description\":\"uint32_value exercises the plain uint32 kind.\",\"examples\":[1]},\"uint64Value\":{\"type\":\"string\",\"description\":\"uint64_value exercises the plain uint64 kind.\",\"examples\":[\"1\"]}},\"description\":\"DescribeScalarShapesRequest exercises plain protobuf scalar kinds.\",\"examples\":[{\"boolFlag\":true,\"bytesValue\":\"aGVsbG8=\",\"details\":{\"label\":\"example\"},\"doubleValue\":1.25,\"fixed32Value\":1,\"fixed64Value\":\"1\",\"floatValue\":1.25,\"int32Value\":-1,\"int64Value\":\"-1\",\"optionalBoolFlag\":true,\"optionalBytesValue\":\"aGVsbG8=\",\"optionalDoubleValue\":1.25,\"optionalFixed32Value\":1,\"optionalFixed64Value\":\"1\",\"optionalFloatValue\":1.25,\"optionalInt32Value\":-1,\"optionalInt64Value\":\"-1\",\"optionalSfixed32Value\":-1,\"optionalSfixed64Value\":\"-1\",\"optionalSint32Value\":-1,\"optionalSint64Value\":\"-1\",\"optionalStatus\":\"REPORT_STATUS_OK\",\"optionalTextValue\":\"example\",\"optionalUint32Value\":1,\"optionalUint64Value\":\"1\",\"samples\":[-1],\"sfixed32Value\":-1,\"sfixed64Value\":\"-1\",\"sint32Value\":-1,\"sint64Value\":\"-1\",\"status\":\"REPORT_STATUS_OK\",\"textValue\":\"example\",\"uint32Value\":1,\"uint64Value\":\"1\"}],\"required\":[\"boolFlag\",\"textValue\",\"bytesValue\",\"int32Value\",\"sint32Value\",\"sfixed32Value\",\"uint32Value\",\"fixed32Value\",\"int64Value\",\"sint64Value\",\"sfixed64Value\",\"uint64Value\",\"fixed64Value\",\"floatValue\",\"doubleValue\",\"status\",\"details\"],\"additionalProperties\":false}"; +export const EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"boolFlag\":{\"type\":\"boolean\",\"description\":\"bool_flag echoes the plain bool kind.\",\"examples\":[true]},\"bytesValue\":{\"type\":\"string\",\"description\":\"bytes_value echoes the plain bytes kind.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details echoes plain nested message handling.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"doubleValue\":{\"description\":\"double_value echoes the plain double kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"fixed32Value\":{\"type\":\"integer\",\"description\":\"fixed32_value echoes the plain fixed32 kind.\",\"examples\":[1]},\"fixed64Value\":{\"type\":\"string\",\"description\":\"fixed64_value echoes the plain fixed64 kind.\",\"examples\":[\"1\"]},\"floatValue\":{\"description\":\"float_value echoes the plain float kind.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]}]},\"int32Value\":{\"type\":\"integer\",\"description\":\"int32_value echoes the plain int32 kind.\",\"examples\":[-1]},\"int64Value\":{\"type\":\"string\",\"description\":\"int64_value echoes the plain int64 kind.\",\"examples\":[\"-1\"]},\"optionalBoolFlag\":{\"type\":[\"boolean\",\"null\"],\"description\":\"optional_bool_flag echoes proto3 optional bool.\",\"examples\":[true]},\"optionalBytesValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_bytes_value echoes proto3 optional bytes.\",\"examples\":[\"aGVsbG8=\"],\"contentEncoding\":\"base64\"},\"optionalDoubleValue\":{\"description\":\"optional_double_value echoes proto3 optional double.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalFixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_fixed32_value echoes proto3 optional fixed32.\",\"examples\":[1]},\"optionalFixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_fixed64_value echoes proto3 optional fixed64.\",\"examples\":[\"1\"]},\"optionalFloatValue\":{\"description\":\"optional_float_value echoes proto3 optional float.\",\"examples\":[1.25,\"NaN\"],\"anyOf\":[{\"type\":\"number\"},{\"type\":\"string\",\"enum\":[\"NaN\",\"Infinity\",\"-Infinity\"]},{\"type\":\"null\"}]},\"optionalInt32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_int32_value echoes proto3 optional int32.\",\"examples\":[-1]},\"optionalInt64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_int64_value echoes proto3 optional int64.\",\"examples\":[\"-1\"]},\"optionalSfixed32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sfixed32_value echoes proto3 optional sfixed32.\",\"examples\":[-1]},\"optionalSfixed64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sfixed64_value echoes proto3 optional sfixed64.\",\"examples\":[\"-1\"]},\"optionalSint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_sint32_value echoes proto3 optional sint32.\",\"examples\":[-1]},\"optionalSint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_sint64_value echoes proto3 optional sint64.\",\"examples\":[\"-1\"]},\"optionalStatus\":{\"description\":\"optional_status echoes proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"anyOf\":[{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"optional_status echoes proto3 optional enum.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},{\"type\":\"null\"}]},\"optionalTextValue\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_text_value echoes proto3 optional string.\",\"examples\":[\"example\"]},\"optionalUint32Value\":{\"type\":[\"integer\",\"null\"],\"description\":\"optional_uint32_value echoes proto3 optional uint32.\",\"examples\":[1]},\"optionalUint64Value\":{\"type\":[\"string\",\"null\"],\"description\":\"optional_uint64_value echoes proto3 optional uint64.\",\"examples\":[\"1\"]},\"samples\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"integer\",\"description\":\"samples echoes repeated plain scalar handling.\",\"examples\":[-1]},\"description\":\"samples echoes repeated plain scalar handling.\",\"examples\":[[-1]]},\"sfixed32Value\":{\"type\":\"integer\",\"description\":\"sfixed32_value echoes the plain sfixed32 kind.\",\"examples\":[-1]},\"sfixed64Value\":{\"type\":\"string\",\"description\":\"sfixed64_value echoes the plain sfixed64 kind.\",\"examples\":[\"-1\"]},\"sint32Value\":{\"type\":\"integer\",\"description\":\"sint32_value echoes the plain sint32 kind.\",\"examples\":[-1]},\"sint64Value\":{\"type\":\"string\",\"description\":\"sint64_value echoes the plain sint64 kind.\",\"examples\":[\"-1\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status echoes plain enum handling.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"textValue\":{\"type\":\"string\",\"description\":\"text_value echoes the plain string kind.\",\"examples\":[\"example\"]},\"uint32Value\":{\"type\":\"integer\",\"description\":\"uint32_value echoes the plain uint32 kind.\",\"examples\":[1]},\"uint64Value\":{\"type\":\"string\",\"description\":\"uint64_value echoes the plain uint64 kind.\",\"examples\":[\"1\"]}},\"description\":\"DescribeScalarShapesResponse echoes plain protobuf scalar kinds.\",\"examples\":[{\"boolFlag\":true,\"bytesValue\":\"aGVsbG8=\",\"details\":{\"label\":\"example\"},\"doubleValue\":1.25,\"fixed32Value\":1,\"fixed64Value\":\"1\",\"floatValue\":1.25,\"int32Value\":-1,\"int64Value\":\"-1\",\"optionalBoolFlag\":true,\"optionalBytesValue\":\"aGVsbG8=\",\"optionalDoubleValue\":1.25,\"optionalFixed32Value\":1,\"optionalFixed64Value\":\"1\",\"optionalFloatValue\":1.25,\"optionalInt32Value\":-1,\"optionalInt64Value\":\"-1\",\"optionalSfixed32Value\":-1,\"optionalSfixed64Value\":\"-1\",\"optionalSint32Value\":-1,\"optionalSint64Value\":\"-1\",\"optionalStatus\":\"REPORT_STATUS_OK\",\"optionalTextValue\":\"example\",\"optionalUint32Value\":1,\"optionalUint64Value\":\"1\",\"samples\":[-1],\"sfixed32Value\":-1,\"sfixed64Value\":\"-1\",\"sint32Value\":-1,\"sint64Value\":\"-1\",\"status\":\"REPORT_STATUS_OK\",\"textValue\":\"example\",\"uint32Value\":1,\"uint64Value\":\"1\"}],\"required\":[\"boolFlag\",\"textValue\",\"bytesValue\",\"int32Value\",\"sint32Value\",\"sfixed32Value\",\"uint32Value\",\"fixed32Value\",\"int64Value\",\"sint64Value\",\"sfixed64Value\",\"uint64Value\",\"fixed64Value\",\"floatValue\",\"doubleValue\",\"status\",\"details\"],\"additionalProperties\":false}"; + +export const EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"description\":\"name is the hidden request payload.\",\"examples\":[\"example\"]}},\"description\":\"HiddenThingRequest is used by the hidden RPC.\",\"deprecated\":true,\"examples\":[{\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}"; +export const EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"description\":\"HiddenThingResponse is used by the hidden RPC.\",\"additionalProperties\":false}"; From 8630f7795023c98d3339820af8e35e89e44aac4e Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sun, 3 May 2026 23:37:59 +0300 Subject: [PATCH 25/74] chore(07-02): add node sdk spike project - add lockfile-backed local npm project - configure strict NodeNext TypeScript compile gate - ignore local node build artifacts --- .gitignore | 2 + examples/node/sdk-spike/README.md | 23 + examples/node/sdk-spike/package-lock.json | 1181 +++++++++++++++++++++ examples/node/sdk-spike/package.json | 19 + examples/node/sdk-spike/tsconfig.json | 14 + 5 files changed, 1239 insertions(+) create mode 100644 examples/node/sdk-spike/README.md create mode 100644 examples/node/sdk-spike/package-lock.json create mode 100644 examples/node/sdk-spike/package.json create mode 100644 examples/node/sdk-spike/tsconfig.json diff --git a/.gitignore b/.gitignore index e124eed..248f20d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ examples/3_file_manager/file-server examples/4_crm_system/crm-server examples/jvm/.gradle/ examples/jvm/*/build/ +examples/node/**/node_modules/ +examples/node/**/dist/ examples/6_java_standalone/.gradle/ examples/6_java_standalone/build/ examples/7_kotlin_standalone/.gradle/ diff --git a/examples/node/sdk-spike/README.md b/examples/node/sdk-spike/README.md new file mode 100644 index 0000000..f3a37c7 --- /dev/null +++ b/examples/node/sdk-spike/README.md @@ -0,0 +1,23 @@ +# TypeScript SDK Spike + +This is a compile-only spike for the future generated TypeScript MCP runtime. +It proves that the selected stable Node stack can support the low-level server +seam that `protoc-gen-mcp` needs before the full renderer is implemented. + +The spike intentionally uses: + +- `@modelcontextprotocol/sdk` low-level `Server.setRequestHandler(...)` +- raw JSON Schema objects for `inputSchema` and `outputSchema` +- Ajv 2020 validation +- Protobuf-ES `fromJson(...)` and `toJson(...)` +- strict NodeNext TypeScript + +It intentionally does not use SDK `registerTool(...)`, generated Zod schemas, +private SDK fields, or a direct JavaScript renderer. + +## Commands + +```bash +npm ci +npm run typecheck +``` diff --git a/examples/node/sdk-spike/package-lock.json b/examples/node/sdk-spike/package-lock.json new file mode 100644 index 0000000..4063e93 --- /dev/null +++ b/examples/node/sdk-spike/package-lock.json @@ -0,0 +1,1181 @@ +{ + "name": "protoc-gen-mcp-sdk-spike", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "protoc-gen-mcp-sdk-spike", + "version": "0.0.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@modelcontextprotocol/sdk": "1.29.0", + "ajv": "8.20.0" + }, + "devDependencies": { + "@types/node": "24.12.2", + "typescript": "6.0.3" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.16", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", + "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/examples/node/sdk-spike/package.json b/examples/node/sdk-spike/package.json new file mode 100644 index 0000000..0c60b62 --- /dev/null +++ b/examples/node/sdk-spike/package.json @@ -0,0 +1,19 @@ +{ + "name": "protoc-gen-mcp-sdk-spike", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@modelcontextprotocol/sdk": "1.29.0", + "ajv": "8.20.0" + }, + "devDependencies": { + "@types/node": "24.12.2", + "typescript": "6.0.3" + } +} diff --git a/examples/node/sdk-spike/tsconfig.json b/examples/node/sdk-spike/tsconfig.json new file mode 100644 index 0000000..987eda2 --- /dev/null +++ b/examples/node/sdk-spike/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "sourceMap": true, + "rootDir": "src", + "outDir": "dist", + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} From 7382d8d2ff1974c49e3d8cf91d0fe1bf2bab4748 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sun, 3 May 2026 23:38:12 +0300 Subject: [PATCH 26/74] test(07-02): prove low-level typescript sdk seam - compile raw schema tools/list and tools/call handlers - exercise Protobuf-ES Struct ProtoJSON conversion - avoid registerTool and Zod schema generation --- examples/node/sdk-spike/src/spike.ts | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 examples/node/sdk-spike/src/spike.ts diff --git a/examples/node/sdk-spike/src/spike.ts b/examples/node/sdk-spike/src/spike.ts new file mode 100644 index 0000000..c413e2c --- /dev/null +++ b/examples/node/sdk-spike/src/spike.ts @@ -0,0 +1,74 @@ +import { fromJson, toJson, type JsonValue } from "@bufbuild/protobuf"; +import { StructSchema } from "@bufbuild/protobuf/wkt"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type CallToolResult, + type Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import { Ajv2020 } from "ajv/dist/2020.js"; + +const inputSchema = { + type: "object", + properties: { + label: { type: "string" }, + }, + required: ["label"], + additionalProperties: false, +} satisfies Tool["inputSchema"]; + +const outputSchema = { + type: "object", + properties: { + label: { type: "string" }, + }, + required: ["label"], + additionalProperties: false, +} satisfies NonNullable; + +const ajv = new Ajv2020({ strict: false }); +const validateInput = ajv.compile(inputSchema); +const validateOutput = ajv.compile(outputSchema); + +const server = new Server( + { name: "ts-spike", version: "0.0.0" }, + { capabilities: { tools: {} } }, +); + +server.setRequestHandler(ListToolsRequestSchema, async (): Promise<{ tools: Tool[] }> => ({ + tools: [ + { + name: "spike_echo", + description: "Raw schema echo spike", + inputSchema, + outputSchema, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { + const args = request.params.arguments ?? {}; + if (!validateInput(args)) { + return { + content: [{ type: "text", text: "invalid input" }], + isError: true, + }; + } + + const message = fromJson(StructSchema, args as JsonValue); + const payload = toJson(StructSchema, message, { + alwaysEmitImplicit: true, + }) as Record; + + if (!validateOutput(payload)) { + throw new Error("output validation failed"); + } + + return { + content: [{ type: "text", text: JSON.stringify(payload) }], + structuredContent: payload, + }; +}); + +void server; From 78e55eff5fe104dfe0c11b836ccd34767ea5fc35 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 01:46:53 +0300 Subject: [PATCH 27/74] test(08-01): add failing test for typescript semantic model - Assert shared TypeScript model semantics are copied from FileModel - Cover descriptor and SDK neutrality plus request and response refs --- internal/codegen/typescript_model_test.go | 300 ++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 internal/codegen/typescript_model_test.go diff --git a/internal/codegen/typescript_model_test.go b/internal/codegen/typescript_model_test.go new file mode 100644 index 0000000..623befa --- /dev/null +++ b/internal/codegen/typescript_model_test.go @@ -0,0 +1,300 @@ +package codegen + +import ( + "reflect" + "strings" + "testing" + + mcpoptionsv1 "github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1" +) + +func TestTypeScriptModel_StructuralIRDoesNotContainRawDescriptorsOrSDKTypes(t *testing.T) { + disallowed := map[string]struct{}{ + "google.golang.org/protobuf/compiler/protogen.Enum": {}, + "google.golang.org/protobuf/compiler/protogen.Field": {}, + "google.golang.org/protobuf/compiler/protogen.File": {}, + "google.golang.org/protobuf/compiler/protogen.Message": {}, + "google.golang.org/protobuf/compiler/protogen.Method": {}, + "google.golang.org/protobuf/compiler/protogen.Service": {}, + "google.golang.org/protobuf/reflect/protoreflect.Descriptor": {}, + "google.golang.org/protobuf/reflect/protoreflect.FileDescriptor": {}, + } + + assertIRTypeHasNoDisallowedFields(t, reflect.TypeOf(TypeScriptFileModel{}), disallowed) + assertIRTypeHasNoPackageSubstring(t, reflect.TypeOf(TypeScriptFileModel{}), "modelcontextprotocol") + assertIRTypeHasNoPackageSubstring(t, reflect.TypeOf(TypeScriptFileModel{}), "bufbuild/protobuf") +} + +func TestTypeScriptModel_PreservesSharedToolSemantics(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/metadata.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/metadata;metadatav1";`, + `import "mcp/options/v1/options.proto";`, + `message Request {}`, + `message Response {}`, + `service MetadataService {`, + ` option (mcp.options.v1.service) = {`, + ` namespace: "metadata"`, + ` icons: [{`, + ` src: "https://example.com/service.svg"`, + ` mime_type: "image/svg+xml"`, + ` sizes: "64x64"`, + ` theme: "light"`, + ` }]`, + ` };`, + ``, + ` rpc Visible(Request) returns (Response) {`, + ` option (mcp.options.v1.method) = {`, + ` name: "metadata_visible"`, + ` title: "Visible metadata"`, + ` description: "Returns metadata."`, + ` annotations: { read_only_hint: true open_world_hint: false }`, + ` execution: { task_support: TASK_SUPPORT_OPTIONAL }`, + ` };`, + ` }`, + ``, + ` rpc OverrideIcon(Request) returns (Response) {`, + ` option (mcp.options.v1.method) = {`, + ` icons: [{`, + ` src: "https://example.com/method.png"`, + ` mime_type: "image/png"`, + ` sizes: "32x32"`, + ` theme: "dark"`, + ` }]`, + ` execution: { task_support: TASK_SUPPORT_REQUIRED }`, + ` };`, + ` }`, + `}`, + "", + }, "\n"), + }, "test/v1/metadata.proto") + file := plugin.FilesByPath["test/v1/metadata.proto"] + if file == nil { + t.Fatal("metadata proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageTypeScript}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + tsModel, err := CollectTypeScriptFileModel(file, shared) + if err != nil { + t.Fatalf("CollectTypeScriptFileModel: %v", err) + } + + if got, want := tsModel.ProtoPath, shared.ProtoPath; got != want { + t.Fatalf("ProtoPath = %q, want %q", got, want) + } + if got, want := tsModel.GeneratedFilenamePrefix, shared.GeneratedFilenamePrefix; got != want { + t.Fatalf("GeneratedFilenamePrefix = %q, want %q", got, want) + } + if len(tsModel.Services) != len(shared.Services) { + t.Fatalf("service count = %d, want %d", len(tsModel.Services), len(shared.Services)) + } + + sharedService := shared.Services[0] + tsService := tsModel.Services[0] + if got, want := tsService.Namespace, sharedService.Namespace; got != want { + t.Fatalf("Namespace = %q, want %q", got, want) + } + if len(tsService.Icons) != 1 || tsService.Icons[0].GetSrc() != sharedService.Icons[0].GetSrc() { + t.Fatalf("service icons = %+v, want %+v", tsService.Icons, sharedService.Icons) + } + + visible := findTypeScriptMethodByProtoName(t, tsService, "Visible") + sharedVisible := findSharedMethodByProtoName(t, sharedService, "Visible") + assertTypeScriptMethodPreservesSharedFields(t, visible, sharedVisible) + if visible.Annotations == nil { + t.Fatal("Visible annotations should be preserved") + } + if !visible.Annotations.GetReadOnlyHint() { + t.Fatal("Visible read_only_hint should be true") + } + if visible.Annotations.OpenWorldHint == nil || visible.Annotations.GetOpenWorldHint() { + t.Fatal("Visible open_world_hint should be explicitly false") + } + if len(visible.Icons) != 1 || visible.Icons[0].GetSrc() != "https://example.com/service.svg" { + t.Fatalf("Visible should inherit service icon, got %+v", visible.Icons) + } + if got, want := visible.TaskSupport, mcpoptionsv1.TaskSupport_TASK_SUPPORT_OPTIONAL; got != want { + t.Fatalf("Visible TaskSupport = %v, want %v", got, want) + } + + override := findTypeScriptMethodByProtoName(t, tsService, "OverrideIcon") + sharedOverride := findSharedMethodByProtoName(t, sharedService, "OverrideIcon") + assertTypeScriptMethodPreservesSharedFields(t, override, sharedOverride) + if len(override.Icons) != 1 || override.Icons[0].GetSrc() != "https://example.com/method.png" { + t.Fatalf("OverrideIcon should preserve method icon, got %+v", override.Icons) + } + if got, want := override.TaskSupport, mcpoptionsv1.TaskSupport_TASK_SUPPORT_REQUIRED; got != want { + t.Fatalf("OverrideIcon TaskSupport = %v, want %v", got, want) + } +} + +func TestTypeScriptModel_ExposesSameFileAndCrossFileRequestResponseRefs(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/shared-types.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/sharedtypes;sharedtypesv1";`, + `message SharedRequest {}`, + `message SharedResponse {}`, + "", + }, "\n"), + "test/v1/service-file.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/servicefile;servicefilev1";`, + `import "test/v1/shared-types.proto";`, + `message LocalRequest {}`, + `message LocalResponse {}`, + `service ServiceAPI {`, + ` rpc UseLocal(LocalRequest) returns (LocalResponse);`, + ` rpc UseShared(SharedRequest) returns (SharedResponse);`, + `}`, + "", + }, "\n"), + }, "test/v1/service-file.proto") + file := plugin.FilesByPath["test/v1/service-file.proto"] + if file == nil { + t.Fatal("service-file proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageTypeScript}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + tsModel, err := CollectTypeScriptFileModel(file, shared) + if err != nil { + t.Fatalf("CollectTypeScriptFileModel: %v", err) + } + + service := tsModel.Services[0] + local := findTypeScriptMethodByProtoName(t, service, "UseLocal") + assertTypeScriptTypeRef(t, local.Input, typeScriptTypeRefWant{ + protoPath: "test/v1/service-file.proto", + current: true, + typeName: "LocalRequest", + schemaName: "LocalRequestSchema", + registryName: "file_test_v1_service_file", + }) + assertTypeScriptTypeRef(t, local.Output, typeScriptTypeRefWant{ + protoPath: "test/v1/service-file.proto", + current: true, + typeName: "LocalResponse", + schemaName: "LocalResponseSchema", + registryName: "file_test_v1_service_file", + }) + + crossFile := findTypeScriptMethodByProtoName(t, service, "UseShared") + assertTypeScriptTypeRef(t, crossFile.Input, typeScriptTypeRefWant{ + protoPath: "test/v1/shared-types.proto", + current: false, + typeName: "SharedRequest", + schemaName: "SharedRequestSchema", + registryName: "file_test_v1_shared_types", + }) + assertTypeScriptTypeRef(t, crossFile.Output, typeScriptTypeRefWant{ + protoPath: "test/v1/shared-types.proto", + current: false, + typeName: "SharedResponse", + schemaName: "SharedResponseSchema", + registryName: "file_test_v1_shared_types", + }) + + if len(tsModel.Imports) != 1 { + t.Fatalf("import count = %d, want 1", len(tsModel.Imports)) + } + if got, want := tsModel.Imports[0].ProtoPath, "test/v1/shared-types.proto"; got != want { + t.Fatalf("Imports[0].ProtoPath = %q, want %q", got, want) + } + if got, want := tsModel.Imports[0].ModuleSpecifier, "./shared_types_pb.js"; got != want { + t.Fatalf("Imports[0].ModuleSpecifier = %q, want %q", got, want) + } +} + +func TestCollectTypeScriptFileModel_RejectsNonTypeScriptTarget(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] + if file == nil { + t.Fatal("example proto file not found in plugin") + } + + shared, err := CollectFileModel(file, Options{Language: LanguageGo}) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + _, err = CollectTypeScriptFileModel(file, shared) + if err == nil { + t.Fatal("CollectTypeScriptFileModel unexpectedly succeeded for go target") + } + if !strings.Contains(err.Error(), "typescript model requires lang=typescript") { + t.Fatalf("CollectTypeScriptFileModel error = %v, want non-TypeScript target rejection", err) + } +} + +func assertTypeScriptMethodPreservesSharedFields(t *testing.T, got TypeScriptMethodModel, want MethodModel) { + t.Helper() + + if got.ToolName != want.Name { + t.Fatalf("%s ToolName = %q, want %q", got.ProtoName, got.ToolName, want.Name) + } + if got.InputSchemaJSON != want.InputSchemaJSON { + t.Fatalf("%s InputSchemaJSON mismatch: got %q want %q", got.ProtoName, got.InputSchemaJSON, want.InputSchemaJSON) + } + if got.OutputSchemaJSON != want.OutputSchemaJSON { + t.Fatalf("%s OutputSchemaJSON mismatch: got %q want %q", got.ProtoName, got.OutputSchemaJSON, want.OutputSchemaJSON) + } + if got.Annotations != want.Annotations { + t.Fatalf("%s Annotations pointer = %p, want shared pointer %p", got.ProtoName, got.Annotations, want.Annotations) + } + if !reflect.DeepEqual(got.Icons, want.Icons) { + t.Fatalf("%s Icons = %+v, want %+v", got.ProtoName, got.Icons, want.Icons) + } + if got.TaskSupport != want.TaskSupport { + t.Fatalf("%s TaskSupport = %v, want %v", got.ProtoName, got.TaskSupport, want.TaskSupport) + } +} + +type typeScriptTypeRefWant struct { + protoPath string + current bool + typeName string + schemaName string + registryName string +} + +func assertTypeScriptTypeRef(t *testing.T, got TypeScriptTypeRef, want typeScriptTypeRefWant) { + t.Helper() + + if got.TypeName != want.typeName { + t.Fatalf("TypeName = %q, want %q", got.TypeName, want.typeName) + } + if got.SchemaName != want.schemaName { + t.Fatalf("%s SchemaName = %q, want %q", got.TypeName, got.SchemaName, want.schemaName) + } + if got.Owner.ProtoPath != want.protoPath { + t.Fatalf("%s Owner.ProtoPath = %q, want %q", got.TypeName, got.Owner.ProtoPath, want.protoPath) + } + if got.Owner.IsCurrentFile != want.current { + t.Fatalf("%s Owner.IsCurrentFile = %t, want %t", got.TypeName, got.Owner.IsCurrentFile, want.current) + } + if got.RegistryRef.RefName != want.registryName { + t.Fatalf("%s RegistryRef.RefName = %q, want %q", got.TypeName, got.RegistryRef.RefName, want.registryName) + } +} + +func findTypeScriptMethodByProtoName(t *testing.T, service TypeScriptServiceModel, protoName string) TypeScriptMethodModel { + t.Helper() + + for _, method := range service.Methods { + if method.ProtoName == protoName { + return method + } + } + + t.Fatalf("typescript method %q not found", protoName) + return TypeScriptMethodModel{} +} From 99a972cca0401877e98706391dd3731ce01ef95d Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 01:49:02 +0300 Subject: [PATCH 28/74] feat(08-01): implement typescript semantic model - Add TypeScript file, service, method, type, import, and registry models - Collect TypeScript refs while preserving shared FileModel semantics --- internal/codegen/typescript_collect.go | 293 +++++++++++++++++++++++++ internal/codegen/typescript_model.go | 76 +++++++ 2 files changed, 369 insertions(+) create mode 100644 internal/codegen/typescript_collect.go create mode 100644 internal/codegen/typescript_model.go diff --git a/internal/codegen/typescript_collect.go b/internal/codegen/typescript_collect.go new file mode 100644 index 0000000..976073e --- /dev/null +++ b/internal/codegen/typescript_collect.go @@ -0,0 +1,293 @@ +package codegen + +import ( + "fmt" + "path" + "path/filepath" + "sort" + "strings" + + "google.golang.org/protobuf/compiler/protogen" +) + +func CollectTypeScriptFileModel(file *protogen.File, model FileModel) (TypeScriptFileModel, error) { + if model.Options.Language != LanguageTypeScript { + return TypeScriptFileModel{}, fmt.Errorf("typescript model requires lang=typescript, got %q", model.Options.Language) + } + + methods, err := collectTypeScriptDescriptorMethods(file) + if err != nil { + return TypeScriptFileModel{}, err + } + methodByFullName := make(map[string]*protogen.Method, len(methods)) + for _, method := range methods { + methodByFullName[string(method.Desc.FullName())] = method + } + + currentFile := newTypeScriptTypeOwner(file.Desc.Path(), file.Desc.Path()) + registryRefs := map[string]TypeScriptRegistryRef{ + currentFile.ProtoPath: currentFile.RegistryRef, + } + imports := map[string]*typeScriptImportAccumulator{} + + tsModel := TypeScriptFileModel{ + Language: model.Options.Language, + ProtoPath: model.ProtoPath, + GeneratedFilenamePrefix: model.GeneratedFilenamePrefix, + ProtoPackage: string(file.Desc.Package()), + CurrentFile: currentFile, + Services: make([]TypeScriptServiceModel, 0, len(model.Services)), + } + + for _, service := range model.Services { + serviceModel := TypeScriptServiceModel{ + ProtoFullName: service.ProtoFullName, + ProtoName: service.ProtoName, + Namespace: service.Namespace, + Description: service.Description, + HandlerName: typescriptHandlerName(service.ProtoName), + RegisterName: typescriptRegisterName(service.ProtoName), + Icons: service.Icons, + Methods: make([]TypeScriptMethodModel, 0, len(service.Methods)), + } + for _, method := range service.Methods { + descriptorMethod := methodByFullName[method.ProtoFullName] + methodModel := newTypeScriptMethodModel(service.ProtoName, method, descriptorMethod, file.Desc.Path()) + trackTypeScriptTypeRef(methodModel.Input, imports, registryRefs) + trackTypeScriptTypeRef(methodModel.Output, imports, registryRefs) + serviceModel.Methods = append(serviceModel.Methods, methodModel) + } + tsModel.Services = append(tsModel.Services, serviceModel) + } + + tsModel.Imports = flattenTypeScriptImports(imports) + tsModel.RegistryRefs = flattenTypeScriptRegistryRefs(registryRefs) + return tsModel, nil +} + +func newTypeScriptMethodModel(serviceName string, method MethodModel, descriptorMethod *protogen.Method, currentProtoPath string) TypeScriptMethodModel { + methodModel := TypeScriptMethodModel{ + ProtoFullName: method.ProtoFullName, + ProtoName: method.ProtoName, + ToolName: method.Name, + Title: method.Title, + Description: method.Description, + Examples: append([]string(nil), method.Examples...), + Deprecated: method.Deprecated, + MethodName: typescriptMethodName(method.ProtoName), + SchemaConst: typescriptSchemaConst(serviceName, method.ProtoName), + Input: newTypeScriptTypeRefFromShared(method.Input), + Output: newTypeScriptTypeRefFromShared(method.Output), + InputSchemaJSON: method.InputSchemaJSON, + OutputSchemaJSON: method.OutputSchemaJSON, + Annotations: method.Annotations, + Icons: method.Icons, + TaskSupport: method.TaskSupport, + } + if descriptorMethod != nil { + methodModel.Input = newTypeScriptTypeRefFromMessage(descriptorMethod.Input, currentProtoPath) + methodModel.Output = newTypeScriptTypeRefFromMessage(descriptorMethod.Output, currentProtoPath) + } + return methodModel +} + +func newTypeScriptTypeRefFromShared(ref TypeRef) TypeScriptTypeRef { + typeName := typescriptExportedIdentifier(ref.ProtoDisplayName) + return TypeScriptTypeRef{ + ProtoFullName: ref.ProtoFullName, + ProtoName: ref.ProtoDisplayName, + TypeName: typeName, + SchemaName: typescriptSchemaName(typeName), + } +} + +func newTypeScriptTypeRefFromMessage(message *protogen.Message, currentProtoPath string) TypeScriptTypeRef { + if message == nil { + return TypeScriptTypeRef{} + } + owner := newTypeScriptTypeOwner(message.Desc.ParentFile().Path(), currentProtoPath) + typeName := typescriptPublicTypeName(message) + return TypeScriptTypeRef{ + ProtoFullName: string(message.Desc.FullName()), + ProtoName: string(message.Desc.Name()), + TypeName: typeName, + SchemaName: typescriptSchemaName(typeName), + Owner: owner, + RegistryRef: owner.RegistryRef, + } +} + +func collectTypeScriptDescriptorMethods(file *protogen.File) ([]*protogen.Method, error) { + var methods []*protogen.Method + for _, service := range file.Services { + for _, method := range service.Methods { + _, include, err := selectToolMethod(method) + if err != nil { + return nil, err + } + if !include { + continue + } + methods = append(methods, method) + } + } + return methods, nil +} + +func newTypeScriptTypeOwner(protoPath, currentProtoPath string) TypeScriptTypeOwner { + registryRef := typescriptRegistryRefForProtoPath(protoPath) + return TypeScriptTypeOwner{ + ProtoPath: protoPath, + IsCurrentFile: protoPath == currentProtoPath, + GeneratedFilenamePrefix: typescriptGeneratedFilenamePrefixForProtoPath(protoPath), + ModuleSpecifier: typescriptProtobufModuleSpecifier(currentProtoPath, protoPath), + RegistryRef: registryRef, + } +} + +type typeScriptImportAccumulator struct { + protoPath string + generatedFilenamePrefix string + moduleSpecifier string + typeNames map[string]struct{} + schemaNames map[string]struct{} + registryRefs map[string]struct{} +} + +func trackTypeScriptTypeRef(ref TypeScriptTypeRef, imports map[string]*typeScriptImportAccumulator, registryRefs map[string]TypeScriptRegistryRef) { + if ref.Owner.ProtoPath == "" { + return + } + registryRefs[ref.Owner.ProtoPath] = ref.RegistryRef + if ref.Owner.IsCurrentFile { + return + } + + acc := imports[ref.Owner.ProtoPath] + if acc == nil { + acc = &typeScriptImportAccumulator{ + protoPath: ref.Owner.ProtoPath, + generatedFilenamePrefix: ref.Owner.GeneratedFilenamePrefix, + moduleSpecifier: ref.Owner.ModuleSpecifier, + typeNames: map[string]struct{}{}, + schemaNames: map[string]struct{}{}, + registryRefs: map[string]struct{}{}, + } + imports[ref.Owner.ProtoPath] = acc + } + if ref.TypeName != "" { + acc.typeNames[ref.TypeName] = struct{}{} + } + if ref.SchemaName != "" { + acc.schemaNames[ref.SchemaName] = struct{}{} + } + if ref.RegistryRef.RefName != "" { + acc.registryRefs[ref.RegistryRef.RefName] = struct{}{} + } +} + +func flattenTypeScriptImports(imports map[string]*typeScriptImportAccumulator) []TypeScriptImport { + protoPaths := make([]string, 0, len(imports)) + for protoPath := range imports { + protoPaths = append(protoPaths, protoPath) + } + sort.Strings(protoPaths) + + out := make([]TypeScriptImport, 0, len(protoPaths)) + for _, protoPath := range protoPaths { + acc := imports[protoPath] + out = append(out, TypeScriptImport{ + ProtoPath: acc.protoPath, + GeneratedFilenamePrefix: acc.generatedFilenamePrefix, + ModuleSpecifier: acc.moduleSpecifier, + TypeNames: sortedStringSet(acc.typeNames), + SchemaNames: sortedStringSet(acc.schemaNames), + RegistryRefs: sortedStringSet(acc.registryRefs), + }) + } + return out +} + +func flattenTypeScriptRegistryRefs(registryRefs map[string]TypeScriptRegistryRef) []TypeScriptRegistryRef { + protoPaths := make([]string, 0, len(registryRefs)) + for protoPath := range registryRefs { + protoPaths = append(protoPaths, protoPath) + } + sort.Strings(protoPaths) + + out := make([]TypeScriptRegistryRef, 0, len(protoPaths)) + for _, protoPath := range protoPaths { + out = append(out, registryRefs[protoPath]) + } + return out +} + +func sortedStringSet(values map[string]struct{}) []string { + out := make([]string, 0, len(values)) + for value := range values { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func typescriptPublicTypeName(message *protogen.Message) string { + return typescriptPublicIdentifier(descriptorTypePath(message.Desc.FullName(), message.Desc.ParentFile().Package())) +} + +func typescriptPublicIdentifier(parts []string) string { + var b strings.Builder + for _, part := range parts { + b.WriteString(typescriptExportedIdentifier(part)) + } + return b.String() +} + +func typescriptExportedIdentifier(value string) string { + return pythonExportedIdentifier(value) +} + +func typescriptMethodName(method string) string { + return jvmLowerCamelIdentifier(method) +} + +func typescriptHandlerName(service string) string { + return typescriptExportedIdentifier(service) + "ToolHandler" +} + +func typescriptRegisterName(service string) string { + return "register" + typescriptExportedIdentifier(service) + "Tools" +} + +func typescriptSchemaName(typeName string) string { + if typeName == "" { + return "" + } + return typeName + "Schema" +} + +func typescriptRegistryRefForProtoPath(protoPath string) TypeScriptRegistryRef { + prefix := typescriptGeneratedFilenamePrefixForProtoPath(protoPath) + return TypeScriptRegistryRef{ + ProtoPath: protoPath, + RefName: "file_" + strings.ReplaceAll(prefix, "/", "_"), + } +} + +func typescriptProtobufModuleSpecifier(currentProtoPath, targetProtoPath string) string { + currentDir := path.Dir(typescriptOutputPathForProtoPath(currentProtoPath)) + targetPath := typescriptGeneratedFilenamePrefixForProtoPath(targetProtoPath) + "_pb.js" + + rel, err := filepath.Rel(currentDir, targetPath) + if err != nil { + rel = targetPath + } + rel = filepath.ToSlash(rel) + if rel == "." { + rel = path.Base(targetPath) + } + if strings.HasPrefix(rel, "../") || strings.HasPrefix(rel, "./") { + return rel + } + return "./" + rel +} diff --git a/internal/codegen/typescript_model.go b/internal/codegen/typescript_model.go new file mode 100644 index 0000000..f60b898 --- /dev/null +++ b/internal/codegen/typescript_model.go @@ -0,0 +1,76 @@ +package codegen + +import mcpoptionsv1 "github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1" + +type TypeScriptFileModel struct { + Language Language + ProtoPath string + GeneratedFilenamePrefix string + ProtoPackage string + CurrentFile TypeScriptTypeOwner + Imports []TypeScriptImport + RegistryRefs []TypeScriptRegistryRef + Services []TypeScriptServiceModel +} + +type TypeScriptServiceModel struct { + ProtoFullName string + ProtoName string + Namespace string + Description string + HandlerName string + RegisterName string + Icons []*mcpoptionsv1.Icon + Methods []TypeScriptMethodModel +} + +type TypeScriptMethodModel struct { + ProtoFullName string + ProtoName string + ToolName string + Title string + Description string + Examples []string + Deprecated bool + MethodName string + SchemaConst string + Input TypeScriptTypeRef + Output TypeScriptTypeRef + InputSchemaJSON string + OutputSchemaJSON string + Annotations *mcpoptionsv1.ToolAnnotations + Icons []*mcpoptionsv1.Icon + // TaskSupport mcpoptionsv1.TaskSupport mirrors the shared method contract. + TaskSupport mcpoptionsv1.TaskSupport +} + +type TypeScriptTypeRef struct { + ProtoFullName string + ProtoName string + TypeName string + SchemaName string + Owner TypeScriptTypeOwner + RegistryRef TypeScriptRegistryRef +} + +type TypeScriptImport struct { + ProtoPath string + GeneratedFilenamePrefix string + ModuleSpecifier string + TypeNames []string + SchemaNames []string + RegistryRefs []string +} + +type TypeScriptTypeOwner struct { + ProtoPath string + IsCurrentFile bool + GeneratedFilenamePrefix string + ModuleSpecifier string + RegistryRef TypeScriptRegistryRef +} + +type TypeScriptRegistryRef struct { + ProtoPath string + RefName string +} From e48dbad5da05e1085ea6f524746f1d8fbd6441b6 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 01:50:34 +0300 Subject: [PATCH 29/74] test(08-01): add failing test for typescript names - Cover NodeNext protobuf module specifiers and Protobuf-ES refs - Require TypeScript collision diagnostics for duplicate imported symbols --- internal/codegen/typescript_names_test.go | 144 ++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 internal/codegen/typescript_names_test.go diff --git a/internal/codegen/typescript_names_test.go b/internal/codegen/typescript_names_test.go new file mode 100644 index 0000000..b956876 --- /dev/null +++ b/internal/codegen/typescript_names_test.go @@ -0,0 +1,144 @@ +package codegen + +import ( + "strings" + "testing" +) + +func TestTypeScriptNames_GeneratedAPINames(t *testing.T) { + if got, want := typescriptMethodName("CreateReport"), "createReport"; got != want { + t.Fatalf("typescriptMethodName = %q, want %q", got, want) + } + if got, want := typescriptHandlerName("ExampleAPI"), "ExampleAPIToolHandler"; got != want { + t.Fatalf("typescriptHandlerName = %q, want %q", got, want) + } + if got, want := typescriptRegisterName("ExampleAPI"), "registerExampleAPITools"; got != want { + t.Fatalf("typescriptRegisterName = %q, want %q", got, want) + } + if got, want := typescriptSchemaConst("ExampleAPI", "CreateReport"), "EXAMPLE_API_CREATE_REPORT"; got != want { + t.Fatalf("typescriptSchemaConst = %q, want %q", got, want) + } +} + +func TestTypeScriptNames_ProtobufModuleSpecifiersAreNodeNextRelative(t *testing.T) { + for _, tc := range []struct { + name string + current string + target string + want string + }{ + { + name: "same directory", + current: "test/v1/service-file.proto", + target: "test/v1/shared-file.proto", + want: "./shared_file_pb.js", + }, + { + name: "parent directory", + current: "test/v1/deep/service.proto", + target: "test/v1/shared-file.proto", + want: "../shared_file_pb.js", + }, + { + name: "sibling branch", + current: "test/v1/deep/service.proto", + target: "test/common/shared-file.proto", + want: "../../common/shared_file_pb.js", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := typescriptProtobufModuleSpecifier(tc.current, tc.target) + if got != tc.want { + t.Fatalf("typescriptProtobufModuleSpecifier = %q, want %q", got, tc.want) + } + if !strings.HasSuffix(got, ".js") { + t.Fatalf("module specifier = %q, want .js extension", got) + } + if !strings.HasPrefix(got, "./") && !strings.HasPrefix(got, "../") { + t.Fatalf("module specifier = %q, want relative prefix", got) + } + if strings.Contains(got, `\`) { + t.Fatalf("module specifier = %q, want POSIX separators", got) + } + }) + } +} + +func TestTypeScriptNames_ProtobufESRefs(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/ref-file.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/reffile;reffilev1";`, + `message Report {`, + ` message Entry {}`, + `}`, + "", + }, "\n"), + }, "test/v1/ref-file.proto") + file := plugin.FilesByPath["test/v1/ref-file.proto"] + if file == nil { + t.Fatal("ref-file proto file not found in plugin") + } + report := file.Messages[0] + entry := report.Messages[0] + + if got, want := typescriptPublicTypeName(report), "Report"; got != want { + t.Fatalf("typescriptPublicTypeName(report) = %q, want %q", got, want) + } + if got, want := typescriptPublicTypeName(entry), "ReportEntry"; got != want { + t.Fatalf("typescriptPublicTypeName(entry) = %q, want %q", got, want) + } + if got, want := typescriptSchemaName("Report"), "ReportSchema"; got != want { + t.Fatalf("typescriptSchemaName = %q, want %q", got, want) + } + if got, want := typescriptFileRegistryRefName("test/v1/ref-file.proto"), "file_test_v1_ref_file"; got != want { + t.Fatalf("typescriptFileRegistryRefName = %q, want %q", got, want) + } + if got, want := typescriptRegistryRefForProtoPath("test/v1/ref-file.proto").RefName, "file_test_v1_ref_file"; got != want { + t.Fatalf("RegistryRef.RefName = %q, want %q", got, want) + } +} + +func TestTypeScriptNames_FailsOnCollision(t *testing.T) { + err := typescriptCheckNameCollision(map[string]string{ + "Report": "test/v1/a.proto", + }, "import type name", "Report", "test/v1/b.proto") + if err == nil { + t.Fatal("typescriptCheckNameCollision unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "typescript") || !strings.Contains(err.Error(), "collision") { + t.Fatalf("collision error = %q, want typescript collision diagnostic", err) + } + + scoped := map[string]map[string]string{} + if err := typescriptCheckOwnedNameCollision(scoped, "test/v1/a.proto", "import type name", "Report", "test.v1.Report"); err != nil { + t.Fatalf("typescriptCheckOwnedNameCollision first insert: %v", err) + } + err = typescriptCheckOwnedNameCollision(scoped, "test/v1/a.proto", "import type name", "Report", "test.v1.OtherReport") + if err == nil { + t.Fatal("typescriptCheckOwnedNameCollision unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "typescript") { + t.Fatalf("owned collision error = %q, want typescript diagnostic", err) + } + + err = validateTypeScriptImportCollisions([]TypeScriptImport{ + { + ProtoPath: "test/v1/a.proto", + TypeNames: []string{"Report"}, + SchemaNames: []string{"ReportSchema"}, + }, + { + ProtoPath: "test/v1/b.proto", + TypeNames: []string{"Report"}, + SchemaNames: []string{"ReportSchema"}, + }, + }) + if err == nil { + t.Fatal("validateTypeScriptImportCollisions unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "typescript") || !strings.Contains(err.Error(), "Report") { + t.Fatalf("import collision error = %q, want TypeScript import symbol diagnostic", err) + } +} From 936258e516d9817e37e17a19470fc928379e504f Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 01:52:11 +0300 Subject: [PATCH 30/74] feat(08-01): add typescript names and import checks - Add TypeScript API names, Protobuf-ES refs, and NodeNext module specifiers - Fail fast on duplicate TypeScript import and protobuf ref names --- internal/codegen/typescript_collect.go | 70 +----------- internal/codegen/typescript_names.go | 145 +++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 64 deletions(-) create mode 100644 internal/codegen/typescript_names.go diff --git a/internal/codegen/typescript_collect.go b/internal/codegen/typescript_collect.go index 976073e..fb799dc 100644 --- a/internal/codegen/typescript_collect.go +++ b/internal/codegen/typescript_collect.go @@ -2,10 +2,7 @@ package codegen import ( "fmt" - "path" - "path/filepath" "sort" - "strings" "google.golang.org/protobuf/compiler/protogen" ) @@ -61,7 +58,13 @@ func CollectTypeScriptFileModel(file *protogen.File, model FileModel) (TypeScrip } tsModel.Imports = flattenTypeScriptImports(imports) + if err := validateTypeScriptImportCollisions(tsModel.Imports); err != nil { + return TypeScriptFileModel{}, err + } tsModel.RegistryRefs = flattenTypeScriptRegistryRefs(registryRefs) + if err := validateTypeScriptModelRefCollisions(tsModel); err != nil { + return TypeScriptFileModel{}, err + } return tsModel, nil } @@ -230,64 +233,3 @@ func sortedStringSet(values map[string]struct{}) []string { sort.Strings(out) return out } - -func typescriptPublicTypeName(message *protogen.Message) string { - return typescriptPublicIdentifier(descriptorTypePath(message.Desc.FullName(), message.Desc.ParentFile().Package())) -} - -func typescriptPublicIdentifier(parts []string) string { - var b strings.Builder - for _, part := range parts { - b.WriteString(typescriptExportedIdentifier(part)) - } - return b.String() -} - -func typescriptExportedIdentifier(value string) string { - return pythonExportedIdentifier(value) -} - -func typescriptMethodName(method string) string { - return jvmLowerCamelIdentifier(method) -} - -func typescriptHandlerName(service string) string { - return typescriptExportedIdentifier(service) + "ToolHandler" -} - -func typescriptRegisterName(service string) string { - return "register" + typescriptExportedIdentifier(service) + "Tools" -} - -func typescriptSchemaName(typeName string) string { - if typeName == "" { - return "" - } - return typeName + "Schema" -} - -func typescriptRegistryRefForProtoPath(protoPath string) TypeScriptRegistryRef { - prefix := typescriptGeneratedFilenamePrefixForProtoPath(protoPath) - return TypeScriptRegistryRef{ - ProtoPath: protoPath, - RefName: "file_" + strings.ReplaceAll(prefix, "/", "_"), - } -} - -func typescriptProtobufModuleSpecifier(currentProtoPath, targetProtoPath string) string { - currentDir := path.Dir(typescriptOutputPathForProtoPath(currentProtoPath)) - targetPath := typescriptGeneratedFilenamePrefixForProtoPath(targetProtoPath) + "_pb.js" - - rel, err := filepath.Rel(currentDir, targetPath) - if err != nil { - rel = targetPath - } - rel = filepath.ToSlash(rel) - if rel == "." { - rel = path.Base(targetPath) - } - if strings.HasPrefix(rel, "../") || strings.HasPrefix(rel, "./") { - return rel - } - return "./" + rel -} diff --git a/internal/codegen/typescript_names.go b/internal/codegen/typescript_names.go new file mode 100644 index 0000000..99b5915 --- /dev/null +++ b/internal/codegen/typescript_names.go @@ -0,0 +1,145 @@ +package codegen + +import ( + "fmt" + "path" + "path/filepath" + "strings" + + "google.golang.org/protobuf/compiler/protogen" +) + +func typescriptPublicTypeName(message *protogen.Message) string { + return typescriptPublicIdentifier(descriptorTypePath(message.Desc.FullName(), message.Desc.ParentFile().Package())) +} + +func typescriptPublicIdentifier(parts []string) string { + var b strings.Builder + for _, part := range parts { + b.WriteString(typescriptExportedIdentifier(part)) + } + return b.String() +} + +func typescriptExportedIdentifier(value string) string { + return pythonExportedIdentifier(value) +} + +func typescriptMethodName(method string) string { + return jvmLowerCamelIdentifier(method) +} + +func typescriptHandlerName(service string) string { + return typescriptExportedIdentifier(service) + "ToolHandler" +} + +func typescriptRegisterName(service string) string { + return "register" + typescriptExportedIdentifier(service) + "Tools" +} + +func typescriptSchemaName(typeName string) string { + if typeName == "" { + return "" + } + return typeName + "Schema" +} + +func typescriptRegistryRefForProtoPath(protoPath string) TypeScriptRegistryRef { + return TypeScriptRegistryRef{ + ProtoPath: protoPath, + RefName: typescriptFileRegistryRefName(protoPath), + } +} + +func typescriptFileRegistryRefName(protoPath string) string { + prefix := typescriptGeneratedFilenamePrefixForProtoPath(protoPath) + return "file_" + strings.ReplaceAll(prefix, "/", "_") +} + +func typescriptProtobufModuleSpecifier(currentProtoPath, targetProtoPath string) string { + currentDir := path.Dir(typescriptOutputPathForProtoPath(currentProtoPath)) + targetPath := typescriptGeneratedFilenamePrefixForProtoPath(targetProtoPath) + "_pb.js" + + rel, err := filepath.Rel(currentDir, targetPath) + if err != nil { + rel = targetPath + } + rel = filepath.ToSlash(rel) + if rel == "." { + rel = path.Base(targetPath) + } + if strings.HasPrefix(rel, "../") || strings.HasPrefix(rel, "./") { + return rel + } + return "./" + rel +} + +func typescriptCheckNameCollision(registry map[string]string, category, name, owner string) error { + if existing, ok := registry[name]; ok && existing != owner { + return fmt.Errorf("typescript %s collision for %q between %s and %s", category, name, existing, owner) + } + registry[name] = owner + return nil +} + +func typescriptCheckOwnedNameCollision(scoped map[string]map[string]string, scope, category, name, owner string) error { + registry := scoped[scope] + if registry == nil { + registry = map[string]string{} + scoped[scope] = registry + } + return typescriptCheckNameCollision(registry, category, name, owner) +} + +func validateTypeScriptImportCollisions(imports []TypeScriptImport) error { + typeNames := map[string]string{} + schemaNames := map[string]string{} + registryRefs := map[string]string{} + + for _, imp := range imports { + for _, name := range imp.TypeNames { + if err := typescriptCheckNameCollision(typeNames, "import type name", name, imp.ProtoPath); err != nil { + return err + } + } + for _, name := range imp.SchemaNames { + if err := typescriptCheckNameCollision(schemaNames, "import value name", name, imp.ProtoPath); err != nil { + return err + } + } + for _, name := range imp.RegistryRefs { + if err := typescriptCheckNameCollision(registryRefs, "registry ref", name, imp.ProtoPath); err != nil { + return err + } + } + } + + return nil +} + +func validateTypeScriptModelRefCollisions(model TypeScriptFileModel) error { + typeNames := map[string]string{} + schemaNames := map[string]string{} + registryRefs := map[string]string{} + + for _, service := range model.Services { + for _, method := range service.Methods { + for _, ref := range []TypeScriptTypeRef{method.Input, method.Output} { + if ref.Owner.ProtoPath == "" { + continue + } + if err := typescriptCheckNameCollision(typeNames, "protobuf type name", ref.TypeName, ref.Owner.ProtoPath); err != nil { + return err + } + if err := typescriptCheckNameCollision(schemaNames, "protobuf schema name", ref.SchemaName, ref.Owner.ProtoPath); err != nil { + return err + } + if err := typescriptCheckNameCollision(registryRefs, "protobuf registry ref", ref.RegistryRef.RefName, ref.Owner.ProtoPath); err != nil { + return err + } + } + } + } + + return nil +} From b9693bd080b587947756122a45e1522abb27c7d9 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 01:57:15 +0300 Subject: [PATCH 31/74] test(08-02): add failing TypeScript renderer contracts - Assert low-level SDK imports and ToolRequestContext - Pin typed handler and register function shape - Check schema metadata registry seam and Phase 08 omissions --- internal/codegen/generator_test.go | 9 +- internal/codegen/typescript_contract_test.go | 146 +++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 internal/codegen/typescript_contract_test.go diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 7f80c40..161c795 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -145,6 +145,13 @@ func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { wantSnippets := []string{ "// Code generated by protoc-gen-mcp. DO NOT EDIT.", "// source: internal/testproto/example/v1/example.proto", + `import { Server } from "@modelcontextprotocol/sdk/server/index.js";`, + `import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";`, + `import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";`, + "export type ToolRequestContext = RequestHandlerExtra;", + "export interface ExampleAPIToolHandler", + "export function registerExampleAPITools(", + "namespace?: string | null", "export const EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON", "export const EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON", "export const EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON", @@ -155,7 +162,7 @@ func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { t.Fatalf("generated TypeScript missing snippet %q\n%s", snippet, generated) } } - for _, snippet := range []string{"registerTool", "zod", "lang=javascript"} { + for _, snippet := range []string{"registerTool", "addTool", "zod", "ListToolsRequestSchema", "CallToolRequestSchema", "fromJson", "toJson", "Ajv", "lang=javascript"} { if strings.Contains(generated, snippet) { t.Fatalf("generated TypeScript foundation must not contain snippet %q\n%s", snippet, generated) } diff --git a/internal/codegen/typescript_contract_test.go b/internal/codegen/typescript_contract_test.go new file mode 100644 index 0000000..683ca3e --- /dev/null +++ b/internal/codegen/typescript_contract_test.go @@ -0,0 +1,146 @@ +package codegen + +import ( + "strings" + "testing" +) + +func TestTypeScriptContract_PublicAPIAndLowLevelImports(t *testing.T) { + generated := renderBasicTypeScriptFixture(t) + + wantSnippets := []string{ + `import { Server } from "@modelcontextprotocol/sdk/server/index.js";`, + `import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";`, + `import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";`, + `import type { CreateReportRequest, CreateReportResponse } from "./example_pb.js";`, + `import { CreateReportRequestSchema, CreateReportResponseSchema, file_test_v1_example } from "./example_pb.js";`, + "export type ToolRequestContext = RequestHandlerExtra;", + } + assertTypeScriptContains(t, generated, wantSnippets...) +} + +func TestTypeScriptContract_PublicHandlerAndRegisterShape(t *testing.T) { + generated := renderBasicTypeScriptFixture(t) + + wantSnippets := []string{ + "export interface ExampleAPIToolHandler {", + " createReport(", + " ctx: ToolRequestContext,", + " request: CreateReportRequest,", + " ): CreateReportResponse | Promise;", + "export function registerExampleAPITools(", + " server: Server,", + " impl: ExampleAPIToolHandler,", + " namespace?: string | null,", + "): void", + } + assertTypeScriptContains(t, generated, wantSnippets...) +} + +func TestTypeScriptContract_SchemaConstantsAndRegistryMetadata(t *testing.T) { + generated := renderBasicTypeScriptFixture(t) + + wantSnippets := []string{ + "export const EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON =", + "export const EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON =", + "name: resolveToolName(namespace, \"example_CreateReport\"),", + "handler: impl.createReport.bind(impl),", + "inputSchemaJson: EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON,", + "outputSchemaJson: EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON,", + "inputSchema: CreateReportRequestSchema,", + "outputSchema: CreateReportResponseSchema,", + "fileRegistry: file_test_v1_example,", + "annotations: {", + "readOnlyHint: true", + "destructiveHint: false", + "idempotentHint: true", + "openWorldHint: false", + "icons: [", + `src: "https://example.com/tool.svg"`, + `mimeType: "image/svg+xml"`, + `sizes: "64x64"`, + `theme: "light"`, + "execution: {", + `taskSupport: "optional"`, + } + assertTypeScriptContains(t, generated, wantSnippets...) +} + +func TestTypeScriptContract_Phase08OmitsRuntimeDispatchPaths(t *testing.T) { + generated := renderBasicTypeScriptFixture(t) + + notWantSnippets := []string{ + "registerTool", + "addTool", + "zod", + "ListToolsRequestSchema", + "CallToolRequestSchema", + "fromJson", + "toJson", + "Ajv", + } + assertTypeScriptOmits(t, generated, notWantSnippets...) +} + +func renderBasicTypeScriptFixture(t *testing.T) string { + t.Helper() + + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/example.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/example;examplev1";`, + `import "mcp/options/v1/options.proto";`, + `message CreateReportRequest { string title = 1; }`, + `message CreateReportResponse { string report_id = 1; }`, + `service ExampleAPI {`, + ` option (mcp.options.v1.service) = { namespace: "example" };`, + ` rpc CreateReport(CreateReportRequest) returns (CreateReportResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` title: "Create report"`, + ` description: "Creates a report."`, + ` annotations: {`, + ` read_only_hint: true`, + ` destructive_hint: false`, + ` idempotent_hint: true`, + ` open_world_hint: false`, + ` }`, + ` icons: [{`, + ` src: "https://example.com/tool.svg"`, + ` mime_type: "image/svg+xml"`, + ` sizes: "64x64"`, + ` theme: "light"`, + ` }]`, + ` execution: { task_support: TASK_SUPPORT_OPTIONAL }`, + ` };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/example.proto") + + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate: %v", err) + } + return string(generatedFileContent(t, plugin, "test/v1/example_mcp.ts")) +} + +func assertTypeScriptContains(t *testing.T, generated string, snippets ...string) { + t.Helper() + + for _, snippet := range snippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated TypeScript missing snippet %q\n%s", snippet, generated) + } + } +} + +func assertTypeScriptOmits(t *testing.T, generated string, snippets ...string) { + t.Helper() + + for _, snippet := range snippets { + if strings.Contains(generated, snippet) { + t.Fatalf("generated TypeScript must omit snippet %q\n%s", snippet, generated) + } + } +} From b0941742a2f00f0f3ca54a2ea7dbfc97e2f9df4e Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 02:02:46 +0300 Subject: [PATCH 32/74] feat(08-02): implement TypeScript public renderer API - Collect TypeScript semantic models before rendering - Emit low-level SDK imports, typed handler interfaces, and register functions - Add private registry metadata for schemas, protobuf refs, annotations, icons, and execution --- internal/codegen/generator.go | 10 +- internal/codegen/render_typescript.go | 289 ++++++++++++++++++- internal/codegen/typescript_contract_test.go | 4 +- 3 files changed, 287 insertions(+), 16 deletions(-) diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 1ae1ad5..2dccf79 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -121,8 +121,8 @@ func Generate(plugin *protogen.Plugin, opts Options) error { return nil } -func collectTypeScriptModels(plugin *protogen.Plugin, opts Options) (map[string]FileModel, []*protogen.File, error) { - models := make(map[string]FileModel) +func collectTypeScriptModels(plugin *protogen.Plugin, opts Options) (map[string]TypeScriptFileModel, []*protogen.File, error) { + models := make(map[string]TypeScriptFileModel) orderedFiles := make([]*protogen.File, 0, len(plugin.Files)) for _, file := range plugin.Files { @@ -134,7 +134,11 @@ func collectTypeScriptModels(plugin *protogen.Plugin, opts Options) (map[string] if err != nil { return nil, nil, err } - models[file.Desc.Path()] = model + tsModel, err := CollectTypeScriptFileModel(file, model) + if err != nil { + return nil, nil, err + } + models[file.Desc.Path()] = tsModel orderedFiles = append(orderedFiles, file) } diff --git a/internal/codegen/render_typescript.go b/internal/codegen/render_typescript.go index c841487..ec86d5e 100644 --- a/internal/codegen/render_typescript.go +++ b/internal/codegen/render_typescript.go @@ -4,14 +4,16 @@ import ( "encoding/json" "fmt" "path" + "sort" "strings" + mcpoptionsv1 "github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1" "google.golang.org/protobuf/compiler/protogen" ) -func renderTypeScriptFile(plugin *protogen.Plugin, model FileModel) error { - if model.Options.Language != LanguageTypeScript { - return fmt.Errorf("typescript renderer requires lang=typescript, got %q", model.Options.Language) +func renderTypeScriptFile(plugin *protogen.Plugin, model TypeScriptFileModel) error { + if model.Language != LanguageTypeScript { + return fmt.Errorf("typescript renderer requires lang=typescript, got %q", model.Language) } filename := typescriptOutputPathForProtoPath(model.ProtoPath) @@ -20,19 +22,269 @@ func renderTypeScriptFile(plugin *protogen.Plugin, model FileModel) error { generated.P("// Code generated by protoc-gen-mcp. DO NOT EDIT.") generated.P("// source: ", model.ProtoPath) generated.P() + renderTypeScriptImports(generated, model) + generated.P("export type ToolRequestContext = RequestHandlerExtra;") + generated.P() + renderTypeScriptPrivateTypes(generated) + generated.P() + + for _, service := range model.Services { + renderTypeScriptService(generated, service) + generated.P() + } + + renderTypeScriptPrivateRegistryHelpers(generated) + return nil +} + +func renderTypeScriptImports(generated *protogen.GeneratedFile, model TypeScriptFileModel) { + generated.P(`import { Server } from "@modelcontextprotocol/sdk/server/index.js";`) + generated.P(`import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";`) + generated.P(`import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";`) + + imports := collectTypeScriptRenderImports(model) + if len(imports) == 0 { + generated.P() + return + } + + generated.P() + for _, imp := range imports { + if len(imp.TypeNames) > 0 { + generated.P("import type { ", strings.Join(imp.TypeNames, ", "), " } from ", typescriptStringLiteral(imp.ModuleSpecifier), ";") + } + + valueNames := append([]string{}, imp.SchemaNames...) + valueNames = append(valueNames, imp.RegistryRefs...) + sort.Strings(valueNames) + if len(valueNames) > 0 { + generated.P("import { ", strings.Join(valueNames, ", "), " } from ", typescriptStringLiteral(imp.ModuleSpecifier), ";") + } + } + generated.P() +} - for serviceIndex, service := range model.Services { - for methodIndex, method := range service.Methods { - if serviceIndex > 0 || methodIndex > 0 { - generated.P() +func collectTypeScriptRenderImports(model TypeScriptFileModel) []TypeScriptImport { + importByProtoPath := map[string]*typeScriptImportAccumulator{} + addRef := func(ref TypeScriptTypeRef) { + if ref.Owner.ProtoPath == "" { + return + } + acc := importByProtoPath[ref.Owner.ProtoPath] + if acc == nil { + acc = &typeScriptImportAccumulator{ + protoPath: ref.Owner.ProtoPath, + generatedFilenamePrefix: ref.Owner.GeneratedFilenamePrefix, + moduleSpecifier: ref.Owner.ModuleSpecifier, + typeNames: map[string]struct{}{}, + schemaNames: map[string]struct{}{}, + registryRefs: map[string]struct{}{}, } - schemaConst := typescriptSchemaConst(service.ProtoName, method.ProtoName) - generated.P("export const ", schemaConst, "_INPUT_SCHEMA_JSON = ", typescriptStringLiteral(method.InputSchemaJSON), ";") - generated.P("export const ", schemaConst, "_OUTPUT_SCHEMA_JSON = ", typescriptStringLiteral(method.OutputSchemaJSON), ";") + importByProtoPath[ref.Owner.ProtoPath] = acc + } + if ref.TypeName != "" { + acc.typeNames[ref.TypeName] = struct{}{} + } + if ref.SchemaName != "" { + acc.schemaNames[ref.SchemaName] = struct{}{} + } + if ref.RegistryRef.RefName != "" { + acc.registryRefs[ref.RegistryRef.RefName] = struct{}{} } } - return nil + for _, service := range model.Services { + for _, method := range service.Methods { + addRef(method.Input) + addRef(method.Output) + } + } + + return flattenTypeScriptImports(importByProtoPath) +} + +func renderTypeScriptPrivateTypes(generated *protogen.GeneratedFile) { + generated.P("type ToolAnnotationsMetadata = {") + generated.P(" readOnlyHint?: boolean;") + generated.P(" destructiveHint?: boolean;") + generated.P(" idempotentHint?: boolean;") + generated.P(" openWorldHint?: boolean;") + generated.P(" title?: string;") + generated.P("};") + generated.P() + generated.P("type ToolIconMetadata = {") + generated.P(" src: string;") + generated.P(" mimeType?: string;") + generated.P(" sizes?: string[];") + generated.P(" theme?: string;") + generated.P("};") + generated.P() + generated.P("type ToolExecutionMetadata = {") + generated.P(` taskSupport: "optional" | "required";`) + generated.P("};") + generated.P() + generated.P("type RegisteredTool = {") + generated.P(" name: string;") + generated.P(" title?: string;") + generated.P(" description?: string;") + generated.P(" impl: unknown;") + generated.P(" handler: unknown;") + generated.P(" inputSchemaJson: string;") + generated.P(" outputSchemaJson: string;") + generated.P(" inputSchema: unknown;") + generated.P(" outputSchema: unknown;") + generated.P(" fileRegistry: unknown;") + generated.P(" inputFileRegistry: unknown;") + generated.P(" outputFileRegistry: unknown;") + generated.P(" annotations?: ToolAnnotationsMetadata;") + generated.P(" icons: ToolIconMetadata[];") + generated.P(" execution?: ToolExecutionMetadata;") + generated.P("};") +} + +func renderTypeScriptService(generated *protogen.GeneratedFile, service TypeScriptServiceModel) { + // Public declarations render as `export interface ExampleAPIToolHandler` + // and `export function registerExampleAPITools` for Node consumers. + generated.P("export interface ", service.HandlerName, " {") + for _, method := range service.Methods { + generated.P(" ", method.MethodName, "(") + generated.P(" ctx: ToolRequestContext,") + generated.P(" request: ", method.Input.TypeName, ",") + generated.P(" ): ", method.Output.TypeName, " | Promise<", method.Output.TypeName, ">;") + } + generated.P("}") + generated.P() + + for _, method := range service.Methods { + generated.P("export const ", method.SchemaConst, "_INPUT_SCHEMA_JSON = ", typescriptStringLiteral(method.InputSchemaJSON), ";") + generated.P("export const ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON = ", typescriptStringLiteral(method.OutputSchemaJSON), ";") + generated.P() + } + + generated.P("export function ", service.RegisterName, "(") + generated.P(" server: Server,") + generated.P(" impl: ", service.HandlerName, ",") + generated.P(" namespace?: string | null,") + generated.P("): void {") + generated.P(" const registry = getToolRegistry(server);") + generated.P(" registry.push(") + for _, method := range service.Methods { + renderTypeScriptRegisteredTool(generated, service, method) + generated.P(" },") + } + generated.P(" );") + generated.P("}") +} + +func renderTypeScriptRegisteredTool(generated *protogen.GeneratedFile, service TypeScriptServiceModel, method TypeScriptMethodModel) { + generated.P(" {") + generated.P(" name: resolveToolName(namespace, ", typescriptStringLiteral(method.ToolName), ", ", typescriptStringLiteral(service.Namespace), "),") + if method.Title != "" { + generated.P(" title: ", typescriptStringLiteral(method.Title), ",") + } + if method.Description != "" { + generated.P(" description: ", typescriptStringLiteral(method.Description), ",") + } + generated.P(" impl,") + generated.P(" handler: impl.", method.MethodName, ".bind(impl),") + generated.P(" inputSchemaJson: ", method.SchemaConst, "_INPUT_SCHEMA_JSON,") + generated.P(" outputSchemaJson: ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON,") + generated.P(" inputSchema: ", method.Input.SchemaName, ",") + generated.P(" outputSchema: ", method.Output.SchemaName, ",") + generated.P(" fileRegistry: ", method.Input.RegistryRef.RefName, ",") + generated.P(" inputFileRegistry: ", method.Input.RegistryRef.RefName, ",") + generated.P(" outputFileRegistry: ", method.Output.RegistryRef.RefName, ",") + renderTypeScriptAnnotations(generated, method.Annotations) + renderTypeScriptIcons(generated, method.Icons) + renderTypeScriptExecution(generated, method.TaskSupport) +} + +func renderTypeScriptAnnotations(generated *protogen.GeneratedFile, ann *mcpoptionsv1.ToolAnnotations) { + if ann == nil { + generated.P(" annotations: undefined,") + return + } + + generated.P(" annotations: {") + if ann.ReadOnlyHint { + generated.P(" readOnlyHint: true,") + } + if ann.DestructiveHint != nil { + generated.P(" destructiveHint: ", typescriptBoolLiteral(ann.GetDestructiveHint()), ",") + } + if ann.IdempotentHint { + generated.P(" idempotentHint: true,") + } + if ann.OpenWorldHint != nil { + generated.P(" openWorldHint: ", typescriptBoolLiteral(ann.GetOpenWorldHint()), ",") + } + if ann.Title != "" { + generated.P(" title: ", typescriptStringLiteral(ann.Title), ",") + } + generated.P(" },") +} + +func renderTypeScriptIcons(generated *protogen.GeneratedFile, icons []*mcpoptionsv1.Icon) { + generated.P(" icons: [") + for _, icon := range icons { + generated.P(" {") + generated.P(" src: ", typescriptStringLiteral(icon.GetSrc()), ",") + if icon.GetMimeType() != "" { + generated.P(" mimeType: ", typescriptStringLiteral(icon.GetMimeType()), ",") + } + if len(icon.GetSizes()) > 0 { + generated.P(" sizes: ", typescriptStringArrayLiteral(icon.GetSizes()), ",") + } + if icon.GetTheme() != "" { + generated.P(" theme: ", typescriptStringLiteral(icon.GetTheme()), ",") + } + generated.P(" },") + } + generated.P(" ],") +} + +func renderTypeScriptExecution(generated *protogen.GeneratedFile, taskSupport mcpoptionsv1.TaskSupport) { + switch taskSupport { + case mcpoptionsv1.TaskSupport_TASK_SUPPORT_OPTIONAL: + generated.P(` execution: { taskSupport: "optional" },`) + case mcpoptionsv1.TaskSupport_TASK_SUPPORT_REQUIRED: + generated.P(` execution: { taskSupport: "required" },`) + default: + generated.P(" execution: undefined,") + } +} + +func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { + generated.P("const toolRegistries = new WeakMap();") + generated.P() + generated.P("function getToolRegistry(server: Server): RegisteredTool[] {") + generated.P(" let registry = toolRegistries.get(server);") + generated.P(" if (registry === undefined) {") + generated.P(" registry = [];") + generated.P(" toolRegistries.set(server, registry);") + generated.P(" }") + generated.P(" return registry;") + generated.P("}") + generated.P() + generated.P("function resolveToolName(") + generated.P(" namespace: string | null | undefined,") + generated.P(" defaultName: string,") + generated.P(" defaultNamespace: string,") + generated.P("): string {") + generated.P(" const resolvedNamespace = normalizeNamespace(namespace);") + generated.P(" if (resolvedNamespace === null) {") + generated.P(" const defaultResolvedNamespace = normalizeNamespace(defaultNamespace);") + generated.P(" return defaultResolvedNamespace === null ? defaultName : `${defaultResolvedNamespace}_${defaultName}`;") + generated.P(" }") + generated.P(" return `${resolvedNamespace}_${defaultName}`;") + generated.P("}") + generated.P() + generated.P("function normalizeNamespace(namespace: string | null | undefined): string | null {") + generated.P(" if (namespace === null || namespace === undefined) {") + generated.P(" return null;") + generated.P(" }") + generated.P(" const normalized = namespace.trim().replace(/[.]+/g, \"_\").replace(/_+/g, \"_\").replace(/^_+|_+$/g, \"\");") + generated.P(" return normalized === \"\" ? null : normalized;") } func typescriptOutputPathForProtoPath(protoPath string) string { @@ -59,3 +311,18 @@ func typescriptStringLiteral(value string) string { } return string(encoded) } + +func typescriptBoolLiteral(value bool) string { + if value { + return "true" + } + return "false" +} + +func typescriptStringArrayLiteral(values []string) string { + items := make([]string, 0, len(values)) + for _, value := range values { + items = append(items, typescriptStringLiteral(value)) + } + return "[" + strings.Join(items, ", ") + "]" +} diff --git a/internal/codegen/typescript_contract_test.go b/internal/codegen/typescript_contract_test.go index 683ca3e..646b963 100644 --- a/internal/codegen/typescript_contract_test.go +++ b/internal/codegen/typescript_contract_test.go @@ -43,7 +43,7 @@ func TestTypeScriptContract_SchemaConstantsAndRegistryMetadata(t *testing.T) { wantSnippets := []string{ "export const EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON =", "export const EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON =", - "name: resolveToolName(namespace, \"example_CreateReport\"),", + "name: resolveToolName(namespace, \"CreateReport\", \"example\"),", "handler: impl.createReport.bind(impl),", "inputSchemaJson: EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON,", "outputSchemaJson: EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON,", @@ -58,7 +58,7 @@ func TestTypeScriptContract_SchemaConstantsAndRegistryMetadata(t *testing.T) { "icons: [", `src: "https://example.com/tool.svg"`, `mimeType: "image/svg+xml"`, - `sizes: "64x64"`, + `sizes: ["64x64"]`, `theme: "light"`, "execution: {", `taskSupport: "optional"`, From 43cae1eb0f9f1afd1992eca7bd3a3e25b538a9ea Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 02:05:40 +0300 Subject: [PATCH 33/74] test(08-02): refresh TypeScript public API golden - Lock low-level SDK imports and ToolRequestContext output - Capture typed handler and register function generation - Preserve schema constants without Phase 09 runtime dispatch paths --- testdata/golden/example_mcp.ts.golden | 199 ++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/testdata/golden/example_mcp.ts.golden b/testdata/golden/example_mcp.ts.golden index eb70ed6..25e901f 100644 --- a/testdata/golden/example_mcp.ts.golden +++ b/testdata/golden/example_mcp.ts.golden @@ -1,6 +1,75 @@ // Code generated by protoc-gen-mcp. DO NOT EDIT. // source: internal/testproto/example/v1/example.proto +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js"; + +import type { CreateReportRequest, CreateReportResponse, DescribeAdvancedShapesRequest, DescribeAdvancedShapesResponse, DescribeScalarShapesRequest, DescribeScalarShapesResponse, HiddenThingRequest, HiddenThingResponse, PingRequest, PingResponse } from "./example_pb.js"; +import { CreateReportRequestSchema, CreateReportResponseSchema, DescribeAdvancedShapesRequestSchema, DescribeAdvancedShapesResponseSchema, DescribeScalarShapesRequestSchema, DescribeScalarShapesResponseSchema, HiddenThingRequestSchema, HiddenThingResponseSchema, PingRequestSchema, PingResponseSchema, file_internal_testproto_example_v1_example } from "./example_pb.js"; + +export type ToolRequestContext = RequestHandlerExtra; + +type ToolAnnotationsMetadata = { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + title?: string; +}; + +type ToolIconMetadata = { + src: string; + mimeType?: string; + sizes?: string[]; + theme?: string; +}; + +type ToolExecutionMetadata = { + taskSupport: "optional" | "required"; +}; + +type RegisteredTool = { + name: string; + title?: string; + description?: string; + impl: unknown; + handler: unknown; + inputSchemaJson: string; + outputSchemaJson: string; + inputSchema: unknown; + outputSchema: unknown; + fileRegistry: unknown; + inputFileRegistry: unknown; + outputFileRegistry: unknown; + annotations?: ToolAnnotationsMetadata; + icons: ToolIconMetadata[]; + execution?: ToolExecutionMetadata; +}; + +export interface ExampleAPIToolHandler { + createReport( + ctx: ToolRequestContext, + request: CreateReportRequest, + ): CreateReportResponse | Promise; + ping( + ctx: ToolRequestContext, + request: PingRequest, + ): PingResponse | Promise; + describeAdvancedShapes( + ctx: ToolRequestContext, + request: DescribeAdvancedShapesRequest, + ): DescribeAdvancedShapesResponse | Promise; + describeScalarShapes( + ctx: ToolRequestContext, + request: DescribeScalarShapesRequest, + ): DescribeScalarShapesResponse | Promise; + hiddenThing( + ctx: ToolRequestContext, + request: HiddenThingRequest, + ): HiddenThingResponse | Promise; +} + export const EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"description\":\"City name.\",\"examples\":[\"Paris\",\"London\"],\"minLength\":1,\"maxLength\":100,\"pattern\":\"^[A-Z]\"},\"count\":{\"type\":\"integer\",\"description\":\"count is the number of requested items.\",\"default\":10,\"examples\":[-1],\"minimum\":1,\"maximum\":1000},\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details contains nested report options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"labels\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"labels adds additional labels.\",\"examples\":[\"example\"]},\"description\":\"labels adds additional labels.\",\"examples\":[[\"example\"]],\"minItems\":1,\"maxItems\":50,\"uniqueItems\":true},\"units\":{\"type\":[\"string\",\"null\"],\"description\":\"units overrides the default units.\",\"examples\":[\"example\"]}},\"description\":\"CreateReportRequest describes a report generation request.\",\"examples\":[\"{\\\"city\\\":\\\"Paris\\\",\\\"count\\\":2,\\\"details\\\":{\\\"label\\\":\\\"today\\\"}}\"],\"required\":[\"city\",\"count\",\"details\"],\"additionalProperties\":false}"; export const EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"details\":{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"label is an arbitrary label.\",\"examples\":[\"example\"]}},\"title\":\"Report Details\",\"description\":\"details echoes the nested options.\\n\\nNested report configuration options.\",\"examples\":[{\"label\":\"example\"}],\"required\":[\"label\"],\"additionalProperties\":false},\"reportId\":{\"type\":\"string\",\"description\":\"report_id is the generated report identifier.\",\"examples\":[\"example\"]},\"status\":{\"type\":\"string\",\"title\":\"Report Status\",\"description\":\"status is the final report status.\\n\\nCurrent state of the report.\\n\\nREPORT_STATUS_OK: Report completed successfully.\\nREPORT_STATUS_FAILED: Report generation failed.\",\"examples\":[\"REPORT_STATUS_OK\"],\"enum\":[\"REPORT_STATUS_OK\",\"REPORT_STATUS_FAILED\"]},\"totalCount\":{\"type\":\"string\",\"description\":\"total_count is returned as a ProtoJSON string.\",\"examples\":[\"-1\"]},\"warnings\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"warnings contains optional warning messages.\",\"examples\":[\"example\"]},\"description\":\"warnings contains optional warning messages.\",\"examples\":[[\"example\"]]}},\"description\":\"CreateReportResponse describes a report generation result.\",\"examples\":[{\"details\":{\"label\":\"example\"},\"reportId\":\"example\",\"status\":\"REPORT_STATUS_OK\",\"totalCount\":\"-1\",\"warnings\":[\"example\"]}],\"required\":[\"reportId\",\"totalCount\",\"status\",\"details\"],\"additionalProperties\":false}"; @@ -15,3 +84,133 @@ export const EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON = "{\"type\": export const EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"description\":\"name is the hidden request payload.\",\"examples\":[\"example\"]}},\"description\":\"HiddenThingRequest is used by the hidden RPC.\",\"deprecated\":true,\"examples\":[{\"name\":\"example\"}],\"required\":[\"name\"],\"additionalProperties\":false}"; export const EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"description\":\"HiddenThingResponse is used by the hidden RPC.\",\"additionalProperties\":false}"; + +export function registerExampleAPITools( + server: Server, + impl: ExampleAPIToolHandler, + namespace?: string | null, +): void { + const registry = getToolRegistry(server); + registry.push( + { + name: resolveToolName(namespace, "CreateReport", "example"), + title: "Create report", + description: "Create a report for a city.", + impl, + handler: impl.createReport.bind(impl), + inputSchemaJson: EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + outputSchemaJson: EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + inputSchema: CreateReportRequestSchema, + outputSchema: CreateReportResponseSchema, + fileRegistry: file_internal_testproto_example_v1_example, + inputFileRegistry: file_internal_testproto_example_v1_example, + outputFileRegistry: file_internal_testproto_example_v1_example, + annotations: undefined, + icons: [ + ], + execution: undefined, + }, + { + name: resolveToolName(namespace, "Health", "example"), + title: "Health check", + description: "Ping returns an empty response.", + impl, + handler: impl.ping.bind(impl), + inputSchemaJson: EXAMPLE_API_PING_INPUT_SCHEMA_JSON, + outputSchemaJson: EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON, + inputSchema: PingRequestSchema, + outputSchema: PingResponseSchema, + fileRegistry: file_internal_testproto_example_v1_example, + inputFileRegistry: file_internal_testproto_example_v1_example, + outputFileRegistry: file_internal_testproto_example_v1_example, + annotations: undefined, + icons: [ + ], + execution: undefined, + }, + { + name: resolveToolName(namespace, "DescribeAdvancedShapes", "example"), + title: "Describe advanced shapes", + description: "Exercise maps and well-known protobuf types.", + impl, + handler: impl.describeAdvancedShapes.bind(impl), + inputSchemaJson: EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON, + outputSchemaJson: EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON, + inputSchema: DescribeAdvancedShapesRequestSchema, + outputSchema: DescribeAdvancedShapesResponseSchema, + fileRegistry: file_internal_testproto_example_v1_example, + inputFileRegistry: file_internal_testproto_example_v1_example, + outputFileRegistry: file_internal_testproto_example_v1_example, + annotations: undefined, + icons: [ + ], + execution: undefined, + }, + { + name: resolveToolName(namespace, "DescribeScalarShapes", "example"), + title: "Describe scalar shapes", + description: "Exercise plain protobuf scalar kinds.", + impl, + handler: impl.describeScalarShapes.bind(impl), + inputSchemaJson: EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON, + outputSchemaJson: EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON, + inputSchema: DescribeScalarShapesRequestSchema, + outputSchema: DescribeScalarShapesResponseSchema, + fileRegistry: file_internal_testproto_example_v1_example, + inputFileRegistry: file_internal_testproto_example_v1_example, + outputFileRegistry: file_internal_testproto_example_v1_example, + annotations: undefined, + icons: [ + ], + execution: undefined, + }, + { + name: resolveToolName(namespace, "HiddenThing", "example"), + description: "HiddenThing is intentionally hidden from generated tools.\nNote: the `hidden` method option was removed in this iteration.", + impl, + handler: impl.hiddenThing.bind(impl), + inputSchemaJson: EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON, + outputSchemaJson: EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON, + inputSchema: HiddenThingRequestSchema, + outputSchema: HiddenThingResponseSchema, + fileRegistry: file_internal_testproto_example_v1_example, + inputFileRegistry: file_internal_testproto_example_v1_example, + outputFileRegistry: file_internal_testproto_example_v1_example, + annotations: undefined, + icons: [ + ], + execution: undefined, + }, + ); +} + +const toolRegistries = new WeakMap(); + +function getToolRegistry(server: Server): RegisteredTool[] { + let registry = toolRegistries.get(server); + if (registry === undefined) { + registry = []; + toolRegistries.set(server, registry); + } + return registry; +} + +function resolveToolName( + namespace: string | null | undefined, + defaultName: string, + defaultNamespace: string, +): string { + const resolvedNamespace = normalizeNamespace(namespace); + if (resolvedNamespace === null) { + const defaultResolvedNamespace = normalizeNamespace(defaultNamespace); + return defaultResolvedNamespace === null ? defaultName : `${defaultResolvedNamespace}_${defaultName}`; + } + return `${resolvedNamespace}_${defaultName}`; +} + +function normalizeNamespace(namespace: string | null | undefined): string | null { + if (namespace === null || namespace === undefined) { + return null; + } + const normalized = namespace.trim().replace(/[.]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, ""); + return normalized === "" ? null : normalized; From 59055cabe8a29a721c303dbe6538a6351cbdcad2 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 13:48:50 +0300 Subject: [PATCH 34/74] test(08-03): add failing TypeScript compile smoke - Generates TypeScript sidecar output through lang=typescript - Builds a NodeNext fixture under examples/node/sdk-spike - Invokes the local npm TypeScript compiler --- internal/codegen/typescript_compile_test.go | 157 ++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 internal/codegen/typescript_compile_test.go diff --git a/internal/codegen/typescript_compile_test.go b/internal/codegen/typescript_compile_test.go new file mode 100644 index 0000000..86755b8 --- /dev/null +++ b/internal/codegen/typescript_compile_test.go @@ -0,0 +1,157 @@ +package codegen + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skipf("node is required for TypeScript compile smoke: %v", err) + } + if _, err := exec.LookPath("npm"); err != nil { + t.Skipf("npm is required for TypeScript compile smoke: %v", err) + } + + spikeDir, err := filepath.Abs(filepath.Join("..", "..", "examples", "node", "sdk-spike")) + if err != nil { + t.Fatalf("resolve examples/node/sdk-spike: %v", err) + } + if _, err := os.Stat(filepath.Join(spikeDir, "package.json")); err != nil { + t.Fatalf("examples/node/sdk-spike package.json is required: %v", err) + } + if _, err := os.Stat(filepath.Join(spikeDir, "node_modules", ".bin", "tsc")); err != nil { + t.Fatalf("local TypeScript compiler is required; run npm ci in examples/node/sdk-spike: %v", err) + } + + plugin := newTempProtogenPlugin(t, map[string]string{ + "compile/v1/public-api.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package compile.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/compile;compilev1";`, + `service PublicAPI {`, + ` rpc Render(RenderRequest) returns (RenderResponse);`, + `}`, + `message RenderRequest { string label = 1; }`, + `message RenderResponse { string output = 1; }`, + ``, + }, "\n"), + }, "compile/v1/public-api.proto") + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate TypeScript: %v", err) + } + + tempProject, err := os.MkdirTemp(spikeDir, ".tmp-typescript-compile-*") + if err != nil { + t.Fatalf("create temporary TypeScript compile project under sdk-spike: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tempProject); err != nil { + t.Errorf("remove temporary TypeScript compile project: %v", err) + } + }) + + sourceDir := filepath.Join(tempProject, "compile", "v1") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("create compile fixture source dir: %v", err) + } + + generatedSidecar := generatedFileContent(t, plugin, "compile/v1/public_api_mcp.ts") + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "public_api_mcp.ts"), generatedSidecar) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "public_api_pb.ts"), []byte(protobufESCompileFixtureModule())) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "usage.ts"), []byte(typeScriptPublicAPIUsageFixture())) + writeTypeScriptFixtureFile(t, filepath.Join(tempProject, "tsconfig.json"), []byte(typeScriptCompileFixtureTSConfig())) + + cmd := exec.Command("npm", "exec", "--prefix", spikeDir, "--", "tsc", "-p", filepath.Join(tempProject, "tsconfig.json"), "--noEmit") + cmd.Dir = spikeDir + cmd.Env = append(os.Environ(), "NO_COLOR=1") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("generated TypeScript public API failed NodeNext compile via npm exec:\n%s", string(output)) + } +} + +func writeTypeScriptFixtureFile(t *testing.T, path string, contents []byte) { + t.Helper() + + if err := os.WriteFile(path, bytes.TrimPrefix(contents, []byte("\n")), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func protobufESCompileFixtureModule() string { + return ` +import type { Message } from "@bufbuild/protobuf"; + +export type RenderRequest = Message<"compile.v1.RenderRequest"> & { + label: string; +}; + +export type RenderResponse = Message<"compile.v1.RenderResponse"> & { + output: string; +}; + +export const RenderRequestSchema = { + typeName: "compile.v1.RenderRequest", +}; + +export const RenderResponseSchema = { + typeName: "compile.v1.RenderResponse", +}; + +export const file_compile_v1_public_api = { + proto: { + name: "compile/v1/public-api.proto", + }, +}; +` +} + +func typeScriptPublicAPIUsageFixture() string { + return ` +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { registerPublicAPITools, type PublicAPIToolHandler } from "./public_api_mcp.js"; +import type { RenderRequest, RenderResponse } from "./public_api_pb.js"; + +const handler: PublicAPIToolHandler = { + render(_ctx, request: RenderRequest): RenderResponse { + return { + $typeName: "compile.v1.RenderResponse", + output: request.label.toUpperCase(), + }; + }, +}; + +const server = new Server( + { name: "generated-public-api-compile", version: "0.0.0" }, + { capabilities: { tools: {} } }, +); + +registerPublicAPITools(server, handler, "agent.compile"); + +void server; +` +} + +func typeScriptCompileFixtureTSConfig() string { + return ` +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "verbatimModuleSyntax": true + }, + "include": [ + "compile/v1/public_api_mcp.ts", + "compile/v1/public_api_pb.ts", + "compile/v1/usage.ts" + ] +} +` +} From d07e506b7f0ecd248858aaaa739654f95ab7b494 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 13:50:18 +0300 Subject: [PATCH 35/74] fix(08-03): make generated TypeScript compile smoke pass - Close the generated normalizeNamespace helper body - Refresh the TypeScript golden snapshot - Preserve strict NodeNext compile settings --- internal/codegen/render_typescript.go | 1 + testdata/golden/example_mcp.ts.golden | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/codegen/render_typescript.go b/internal/codegen/render_typescript.go index ec86d5e..678e61c 100644 --- a/internal/codegen/render_typescript.go +++ b/internal/codegen/render_typescript.go @@ -285,6 +285,7 @@ func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { generated.P(" }") generated.P(" const normalized = namespace.trim().replace(/[.]+/g, \"_\").replace(/_+/g, \"_\").replace(/^_+|_+$/g, \"\");") generated.P(" return normalized === \"\" ? null : normalized;") + generated.P("}") } func typescriptOutputPathForProtoPath(protoPath string) string { diff --git a/testdata/golden/example_mcp.ts.golden b/testdata/golden/example_mcp.ts.golden index 25e901f..23aa99f 100644 --- a/testdata/golden/example_mcp.ts.golden +++ b/testdata/golden/example_mcp.ts.golden @@ -214,3 +214,4 @@ function normalizeNamespace(namespace: string | null | undefined): string | null } const normalized = namespace.trim().replace(/[.]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, ""); return normalized === "" ? null : normalized; +} From 6cc43129a4f5da08ff8922a4565541d8c426f348 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 13:51:44 +0300 Subject: [PATCH 36/74] docs(08-03): document TypeScript public API guidance - Add pinned Node TypeScript SDK and Protobuf-ES stack notes - Document generated TypeScript handler and register APIs - Add focused TypeScript verification commands --- AGENTS.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 937445a..0a8ffc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,9 +3,9 @@ ## Scope This repository implements a protobuf-first MCP generator and runtime for Go, -Python, Kotlin, and Java MCP server bindings. The MVP is intentionally narrow -and must stay decision-consistent with the current architecture unless -explicitly revised. +Python, Kotlin, Java, and TypeScript MCP server bindings. The MVP is +intentionally narrow and must stay decision-consistent with the current +architecture unless explicitly revised. ## Stack @@ -23,6 +23,12 @@ explicitly revised. - `io.modelcontextprotocol.sdk:mcp` as the official Java MCP SDK target - `io.modelcontextprotocol:kotlin-sdk-server` as the official Kotlin MCP SDK target +- Node.js with TypeScript `6.0.3` for generated TypeScript compile-gate + verification +- `@modelcontextprotocol/sdk@1.29.0` as the official TypeScript MCP SDK target +- `@bufbuild/protobuf@2.12.0` as the Protobuf-ES runtime and generated `_pb.ts` + target for TypeScript bindings +- `ajv@8.20.0` for the Node raw JSON Schema validation path - `github.com/google/jsonschema-go/jsonschema` for JSON Schema parsing and validation - `github.com/bufbuild/protocompile` for in-process descriptor compilation in @@ -39,7 +45,11 @@ explicitly revised. - `examples`: standalone Go/Python/JVM integration projects; example directories use numeric underscore prefixes such as `1_helloworld`, `4_crm_system`, `5_python_standalone`, `6_java_standalone`, and - `7_kotlin_standalone`, plus the dedicated JVM workspace `examples/jvm` + `7_kotlin_standalone`, plus the dedicated JVM workspace `examples/jvm` and + Node spike workspace `examples/node/sdk-spike` +- `examples/node/sdk-spike`: pinned local Node package scope for TypeScript + SDK, Protobuf-ES, Ajv, package-lock-backed `npm ci`, and strict NodeNext + compile checks - `examples/easyp.lock`: pinned Easyp dependency lock for standalone examples - `examples/mcp`: generated Python `mcp.options.*` protobuf modules for standalone examples; generated from the GitHub dependency declared in @@ -82,6 +92,11 @@ explicitly revised. - `internal/codegen/render_kotlin.go`: self-contained Kotlin sidecar renderer - `internal/codegen/kotlin_contract_test.go`: Kotlin public API, SDK wiring, schema-path, and JVM import contract tests +- `internal/codegen/render_typescript.go`: TypeScript sidecar renderer for + low-level official SDK imports, typed public handlers, namespace-aware + registration, schema constants, and private registry metadata +- `internal/codegen/typescript_*_test.go`: TypeScript semantic model, naming, + renderer contract, golden, and strict NodeNext compile-gate tests - `internal/examplemcp`: reusable example MCP server wiring and stdio smoke test - `internal/pythontest`: hermetic Python test runtime bootstrap used by Go tests that execute generated Python code @@ -152,6 +167,9 @@ explicitly revised. the sidecar class - Generated Java files expose `registerTools(McpServerTransportProvider transportProvider, ToolHandler impl, String namespace)` +- Generated TypeScript files expose `ToolHandler` +- Generated TypeScript files expose + `registerTools(server, impl, namespace?)` - Runtime exposes only the minimal registration options used by generated code - Generated MCP tool names must not contain dots; namespace prefixes and method names are joined with underscores, and any dots in configured segments are @@ -164,8 +182,15 @@ explicitly revised. - Implemented: - `cmd/protoc-gen-mcp` plugin scaffold and generated `*.mcp.go` bindings - - typed plugin option parsing for `lang=go|python|kotlin|java` and + - typed plugin option parsing for `lang=go|python|kotlin|java|typescript` and `python_runtime=google.protobuf|betterproto|grpclib` + - generated TypeScript `*_mcp.ts` sidecars for `lang=typescript`, targeting + the official `@modelcontextprotocol/sdk` low-level `Server` import path and + Protobuf-ES `_pb.js` modules, including typed `ToolHandler` + interfaces, namespace-aware `registerTools(server, impl, + namespace?)`, raw schema JSON constants, imported message schema/file + registry refs, metadata registry records, NodeNext `.js` specifiers, and + strict `verbatimModuleSyntax` import type/value separation - shared JVM foundation for `lang=kotlin` and `lang=java`: parser and generator dispatch accept both targets, collect SDK-neutral `internal/codegen/jvm_*.go` models, preserve existing `FileModel` schema @@ -190,8 +215,9 @@ explicitly revised. `protogen.Options.ParamFunc`, with fail-fast rejection of unknown `protoc-gen-mcp` params - non-Go request preparation synthesizes internal `go_package` metadata for - `.proto` files that omit it, so Python/JVM users do not need Go-specific - proto options just to run `lang=python`, `lang=java`, or `lang=kotlin` + `.proto` files that omit it, so Python/JVM/TypeScript users do not need + Go-specific proto options just to run `lang=python`, `lang=java`, + `lang=kotlin`, or `lang=typescript` - generated self-contained Python `*_mcp.py` bindings for `lang=python,python_runtime=google.protobuf`, including handler protocols, dataclasses, `UNSET`, explicit `oneof` wrapper variants, schema JSON @@ -311,6 +337,11 @@ explicitly revised. - `go test ./internal/codegen -run 'TestJavaContract_.*|TestGenerateJavaExampleGolden|TestGenerateJavaExampleHandlerCompileSmoke|TestGenerate_JavaTargetEmitsOutput' -count=1` for Java golden output, low-level renderer contracts, and the Phase 03 narrow `javac` API compile smoke +- `go test ./internal/codegen -run 'TestTypeScriptModel|TestTypeScriptNames|TestTypeScriptContract|TestGenerateTypeScript|TestGenerate_TypeScript|TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext' -count=1` + for TypeScript semantic model, naming, renderer contract, golden, generator, + and strict NodeNext public API compile coverage +- `cd examples/node/sdk-spike && npm ci && npm run typecheck` + for the pinned local TypeScript SDK/Protobuf-ES compile gate - `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` for the Phase 04 JVM compile gate - `gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist` @@ -368,6 +399,8 @@ explicitly revised. - Keep `mcp/options/v1/options.proto` as the source of truth for the options package `go_package`; do not reintroduce a special Easyp override unless the package layout changes again +- Planning docs are local-only in this repository and should not be committed + unless the user explicitly changes that policy. ## Commands @@ -380,6 +413,13 @@ explicitly revised. - Generate test fixtures: `easyp --cfg easyp.test.yaml generate -p internal/testproto -r .` - Generate standalone example artifacts: - `cd examples && make generate` +- Run TypeScript Node compile gate: + - `cd examples/node/sdk-spike && npm ci && npm run typecheck` +- Run focused TypeScript codegen tests: + - `go test ./internal/codegen -run 'TestTypeScriptModel|TestTypeScriptNames|TestTypeScriptContract|TestGenerateTypeScript|TestGenerate_TypeScript|TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext' -count=1` +- Run TypeScript public API compile smoke: + - `cd examples/node/sdk-spike && npm ci` + - `go test ./internal/codegen -run TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext -count=1` - Run JVM compile gate: - `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` - Install JVM example scripts: From 54da8ff1ca6b421be671135b3accf28edc53174c Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 13:53:22 +0300 Subject: [PATCH 37/74] chore(08-03): run phase verification gates - Verified focused TypeScript codegen suite - Verified full internal/codegen package - Verified local Node SDK spike typecheck From f13186afcfe78b26ea2a17d5688639b039d85be9 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Mon, 4 May 2026 14:09:54 +0300 Subject: [PATCH 38/74] fix(08): normalize typescript public api names --- internal/codegen/render_typescript.go | 24 ++++++----- internal/codegen/typescript_compile_test.go | 42 ++++++++++++++++-- internal/codegen/typescript_contract_test.go | 45 ++++++++++++++++++++ internal/codegen/typescript_names.go | 9 ++-- internal/codegen/typescript_names_test.go | 5 ++- testdata/golden/example_mcp.ts.golden | 24 ++++++----- 6 files changed, 122 insertions(+), 27 deletions(-) diff --git a/internal/codegen/render_typescript.go b/internal/codegen/render_typescript.go index 678e61c..9a41575 100644 --- a/internal/codegen/render_typescript.go +++ b/internal/codegen/render_typescript.go @@ -271,20 +271,24 @@ func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { generated.P(" defaultName: string,") generated.P(" defaultNamespace: string,") generated.P("): string {") - generated.P(" const resolvedNamespace = normalizeNamespace(namespace);") - generated.P(" if (resolvedNamespace === null) {") - generated.P(" const defaultResolvedNamespace = normalizeNamespace(defaultNamespace);") - generated.P(" return defaultResolvedNamespace === null ? defaultName : `${defaultResolvedNamespace}_${defaultName}`;") + generated.P(" const resolvedNamespace = normalizeToolSegment(") + generated.P(" namespace === null || namespace === undefined ? defaultNamespace : namespace,") + generated.P(" );") + generated.P(" const resolvedName = normalizeToolSegment(defaultName);") + generated.P(" if (resolvedNamespace === \"\") {") + generated.P(" return resolvedName;") + generated.P(" }") + generated.P(" if (resolvedName === \"\") {") + generated.P(" return resolvedNamespace;") generated.P(" }") - generated.P(" return `${resolvedNamespace}_${defaultName}`;") + generated.P(" return `${resolvedNamespace}_${resolvedName}`;") generated.P("}") generated.P() - generated.P("function normalizeNamespace(namespace: string | null | undefined): string | null {") - generated.P(" if (namespace === null || namespace === undefined) {") - generated.P(" return null;") + generated.P("function normalizeToolSegment(segment: string | null | undefined): string {") + generated.P(" if (segment === null || segment === undefined) {") + generated.P(" return \"\";") generated.P(" }") - generated.P(" const normalized = namespace.trim().replace(/[.]+/g, \"_\").replace(/_+/g, \"_\").replace(/^_+|_+$/g, \"\");") - generated.P(" return normalized === \"\" ? null : normalized;") + generated.P(" return segment.trim().replace(/[.]+/g, \"_\").replace(/_+/g, \"_\").replace(/^_+|_+$/g, \"\");") generated.P("}") } diff --git a/internal/codegen/typescript_compile_test.go b/internal/codegen/typescript_compile_test.go index 86755b8..84f92f7 100644 --- a/internal/codegen/typescript_compile_test.go +++ b/internal/codegen/typescript_compile_test.go @@ -33,8 +33,17 @@ func TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext(t *testing.T) { `syntax = "proto3";`, `package compile.v1;`, `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/compile;compilev1";`, + `import "mcp/options/v1/options.proto";`, `service PublicAPI {`, - ` rpc Render(RenderRequest) returns (RenderResponse);`, + ` option (mcp.options.v1.service) = { namespace: "compile.default" };`, + ` rpc Render(RenderRequest) returns (RenderResponse) {`, + ` option (mcp.options.v1.method) = { name: "render.output" };`, + ` }`, + ` rpc RenderNested(Render.NestedRequest) returns (Render.NestedResponse);`, + `}`, + `message Render {`, + ` message NestedRequest { string label = 1; }`, + ` message NestedResponse { string output = 1; }`, `}`, `message RenderRequest { string label = 1; }`, `message RenderResponse { string output = 1; }`, @@ -95,6 +104,14 @@ export type RenderResponse = Message<"compile.v1.RenderResponse"> & { output: string; }; +export type Render_NestedRequest = Message<"compile.v1.Render.NestedRequest"> & { + label: string; +}; + +export type Render_NestedResponse = Message<"compile.v1.Render.NestedResponse"> & { + output: string; +}; + export const RenderRequestSchema = { typeName: "compile.v1.RenderRequest", }; @@ -103,6 +120,14 @@ export const RenderResponseSchema = { typeName: "compile.v1.RenderResponse", }; +export const Render_NestedRequestSchema = { + typeName: "compile.v1.Render.NestedRequest", +}; + +export const Render_NestedResponseSchema = { + typeName: "compile.v1.Render.NestedResponse", +}; + export const file_compile_v1_public_api = { proto: { name: "compile/v1/public-api.proto", @@ -115,7 +140,12 @@ func typeScriptPublicAPIUsageFixture() string { return ` import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { registerPublicAPITools, type PublicAPIToolHandler } from "./public_api_mcp.js"; -import type { RenderRequest, RenderResponse } from "./public_api_pb.js"; +import type { + Render_NestedRequest, + Render_NestedResponse, + RenderRequest, + RenderResponse, +} from "./public_api_pb.js"; const handler: PublicAPIToolHandler = { render(_ctx, request: RenderRequest): RenderResponse { @@ -124,6 +154,12 @@ const handler: PublicAPIToolHandler = { output: request.label.toUpperCase(), }; }, + renderNested(_ctx, request: Render_NestedRequest): Render_NestedResponse { + return { + $typeName: "compile.v1.Render.NestedResponse", + output: request.label.toLowerCase(), + }; + }, }; const server = new Server( @@ -131,7 +167,7 @@ const server = new Server( { capabilities: { tools: {} } }, ); -registerPublicAPITools(server, handler, "agent.compile"); +registerPublicAPITools(server, handler, ""); void server; ` diff --git a/internal/codegen/typescript_contract_test.go b/internal/codegen/typescript_contract_test.go index 646b963..8ef68cf 100644 --- a/internal/codegen/typescript_contract_test.go +++ b/internal/codegen/typescript_contract_test.go @@ -66,6 +66,24 @@ func TestTypeScriptContract_SchemaConstantsAndRegistryMetadata(t *testing.T) { assertTypeScriptContains(t, generated, wantSnippets...) } +func TestTypeScriptContract_ToolNameNormalization(t *testing.T) { + generated := renderTypeScriptToolNameFixture(t) + + wantSnippets := []string{ + `name: resolveToolName(namespace, "Create.Report", "example.default"),`, + "const resolvedNamespace = normalizeToolSegment(", + "namespace === null || namespace === undefined ? defaultNamespace : namespace,", + "const resolvedName = normalizeToolSegment(defaultName);", + `if (resolvedNamespace === "") {`, + `if (resolvedName === "") {`, + "return `${resolvedNamespace}_${resolvedName}`;", + "function normalizeToolSegment(segment: string | null | undefined): string {", + `return segment.trim().replace(/[.]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");`, + } + assertTypeScriptContains(t, generated, wantSnippets...) + assertTypeScriptOmits(t, generated, "function normalizeNamespace(namespace:", "return `${resolvedNamespace}_${defaultName}`;") +} + func TestTypeScriptContract_Phase08OmitsRuntimeDispatchPaths(t *testing.T) { generated := renderBasicTypeScriptFixture(t) @@ -125,6 +143,33 @@ func renderBasicTypeScriptFixture(t *testing.T) string { return string(generatedFileContent(t, plugin, "test/v1/example_mcp.ts")) } +func renderTypeScriptToolNameFixture(t *testing.T) string { + t.Helper() + + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/tool-name.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/toolname;toolnamev1";`, + `import "mcp/options/v1/options.proto";`, + `message CreateReportRequest { string title = 1; }`, + `message CreateReportResponse { string report_id = 1; }`, + `service ExampleAPI {`, + ` option (mcp.options.v1.service) = { namespace: "example.default" };`, + ` rpc CreateReport(CreateReportRequest) returns (CreateReportResponse) {`, + ` option (mcp.options.v1.method) = { name: "Create.Report" };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/tool-name.proto") + + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate: %v", err) + } + return string(generatedFileContent(t, plugin, "test/v1/tool_name_mcp.ts")) +} + func assertTypeScriptContains(t *testing.T, generated string, snippets ...string) { t.Helper() diff --git a/internal/codegen/typescript_names.go b/internal/codegen/typescript_names.go index 99b5915..a7ca2a7 100644 --- a/internal/codegen/typescript_names.go +++ b/internal/codegen/typescript_names.go @@ -14,11 +14,14 @@ func typescriptPublicTypeName(message *protogen.Message) string { } func typescriptPublicIdentifier(parts []string) string { - var b strings.Builder + names := make([]string, 0, len(parts)) for _, part := range parts { - b.WriteString(typescriptExportedIdentifier(part)) + name := typescriptExportedIdentifier(part) + if name != "" { + names = append(names, name) + } } - return b.String() + return strings.Join(names, "_") } func typescriptExportedIdentifier(value string) string { diff --git a/internal/codegen/typescript_names_test.go b/internal/codegen/typescript_names_test.go index b956876..4093c64 100644 --- a/internal/codegen/typescript_names_test.go +++ b/internal/codegen/typescript_names_test.go @@ -86,9 +86,12 @@ func TestTypeScriptNames_ProtobufESRefs(t *testing.T) { if got, want := typescriptPublicTypeName(report), "Report"; got != want { t.Fatalf("typescriptPublicTypeName(report) = %q, want %q", got, want) } - if got, want := typescriptPublicTypeName(entry), "ReportEntry"; got != want { + if got, want := typescriptPublicTypeName(entry), "Report_Entry"; got != want { t.Fatalf("typescriptPublicTypeName(entry) = %q, want %q", got, want) } + if got, want := typescriptSchemaName(typescriptPublicTypeName(entry)), "Report_EntrySchema"; got != want { + t.Fatalf("typescriptSchemaName(entry) = %q, want %q", got, want) + } if got, want := typescriptSchemaName("Report"), "ReportSchema"; got != want { t.Fatalf("typescriptSchemaName = %q, want %q", got, want) } diff --git a/testdata/golden/example_mcp.ts.golden b/testdata/golden/example_mcp.ts.golden index 23aa99f..0525268 100644 --- a/testdata/golden/example_mcp.ts.golden +++ b/testdata/golden/example_mcp.ts.golden @@ -200,18 +200,22 @@ function resolveToolName( defaultName: string, defaultNamespace: string, ): string { - const resolvedNamespace = normalizeNamespace(namespace); - if (resolvedNamespace === null) { - const defaultResolvedNamespace = normalizeNamespace(defaultNamespace); - return defaultResolvedNamespace === null ? defaultName : `${defaultResolvedNamespace}_${defaultName}`; + const resolvedNamespace = normalizeToolSegment( + namespace === null || namespace === undefined ? defaultNamespace : namespace, + ); + const resolvedName = normalizeToolSegment(defaultName); + if (resolvedNamespace === "") { + return resolvedName; + } + if (resolvedName === "") { + return resolvedNamespace; } - return `${resolvedNamespace}_${defaultName}`; + return `${resolvedNamespace}_${resolvedName}`; } -function normalizeNamespace(namespace: string | null | undefined): string | null { - if (namespace === null || namespace === undefined) { - return null; +function normalizeToolSegment(segment: string | null | undefined): string { + if (segment === null || segment === undefined) { + return ""; } - const normalized = namespace.trim().replace(/[.]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, ""); - return normalized === "" ? null : normalized; + return segment.trim().replace(/[.]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, ""); } From 35c86050dab3acdf0fa490f823e6097740810e5a Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 16:49:01 +0300 Subject: [PATCH 39/74] test(09-01): add TypeScript runtime list contract red tests - require generated low-level MCP runtime imports and helpers - keep rejecting high-level SDK, Zod, and direct JavaScript paths --- internal/codegen/generator_test.go | 12 +++++++++- internal/codegen/typescript_contract_test.go | 23 +++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 161c795..69662eb 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -156,13 +156,23 @@ func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { "export const EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON", "export const EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON", "export const EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON", + `import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf";`, + `import type { JsonValue, Registry } from "@bufbuild/protobuf";`, + `import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";`, + `import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`, + `import { Ajv2020 } from "ajv/dist/2020.js";`, + "type ServerToolRegistry = {", + "function installMcpHandlers(server: Server, registry: ServerToolRegistry): void {", + "function listRegisteredTools(registry: ServerToolRegistry): Tool[] {", + "function buildListTool(tool: RegisteredTool): Tool {", + "function loadSchema(rawSchemaJson: string): Record {", } for _, snippet := range wantSnippets { if !strings.Contains(generated, snippet) { t.Fatalf("generated TypeScript missing snippet %q\n%s", snippet, generated) } } - for _, snippet := range []string{"registerTool", "addTool", "zod", "ListToolsRequestSchema", "CallToolRequestSchema", "fromJson", "toJson", "Ajv", "lang=javascript"} { + for _, snippet := range []string{"registerTool", "addTool", "zod", "toJsonSchemaCompat", "lang=javascript"} { if strings.Contains(generated, snippet) { t.Fatalf("generated TypeScript foundation must not contain snippet %q\n%s", snippet, generated) } diff --git a/internal/codegen/typescript_contract_test.go b/internal/codegen/typescript_contract_test.go index 8ef68cf..d75089c 100644 --- a/internal/codegen/typescript_contract_test.go +++ b/internal/codegen/typescript_contract_test.go @@ -84,18 +84,29 @@ func TestTypeScriptContract_ToolNameNormalization(t *testing.T) { assertTypeScriptOmits(t, generated, "function normalizeNamespace(namespace:", "return `${resolvedNamespace}_${defaultName}`;") } -func TestTypeScriptContract_Phase08OmitsRuntimeDispatchPaths(t *testing.T) { +func TestTypeScriptContract_RuntimeDispatchPaths(t *testing.T) { generated := renderBasicTypeScriptFixture(t) + wantSnippets := []string{ + `import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf";`, + `import type { JsonValue, Registry } from "@bufbuild/protobuf";`, + `import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";`, + `import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`, + `import { Ajv2020 } from "ajv/dist/2020.js";`, + "type ServerToolRegistry = {", + "function installMcpHandlers(server: Server, registry: ServerToolRegistry): void {", + "function listRegisteredTools(registry: ServerToolRegistry): Tool[] {", + "function buildListTool(tool: RegisteredTool): Tool {", + "function loadSchema(rawSchemaJson: string): Record {", + } + assertTypeScriptContains(t, generated, wantSnippets...) + notWantSnippets := []string{ "registerTool", "addTool", "zod", - "ListToolsRequestSchema", - "CallToolRequestSchema", - "fromJson", - "toJson", - "Ajv", + "toJsonSchemaCompat", + "lang=javascript", } assertTypeScriptOmits(t, generated, notWantSnippets...) } From e30a38bb9977cef003483d50d8a25145e1cc4023 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 16:53:04 +0300 Subject: [PATCH 40/74] feat(09-01): install TypeScript low-level list handlers - add generated ServerToolRegistry runtime shell with tools/list projection - install low-level MCP request handlers once per server and preserve raw schemas --- internal/codegen/generator_test.go | 2 +- internal/codegen/render_typescript.go | 100 ++++++++++++++++++- internal/codegen/typescript_contract_test.go | 2 +- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 69662eb..83c771c 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -147,7 +147,7 @@ func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { "// source: internal/testproto/example/v1/example.proto", `import { Server } from "@modelcontextprotocol/sdk/server/index.js";`, `import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";`, - `import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";`, + `import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`, "export type ToolRequestContext = RequestHandlerExtra;", "export interface ExampleAPIToolHandler", "export function registerExampleAPITools(", diff --git a/internal/codegen/render_typescript.go b/internal/codegen/render_typescript.go index 9a41575..5656c42 100644 --- a/internal/codegen/render_typescript.go +++ b/internal/codegen/render_typescript.go @@ -38,9 +38,13 @@ func renderTypeScriptFile(plugin *protogen.Plugin, model TypeScriptFileModel) er } func renderTypeScriptImports(generated *protogen.GeneratedFile, model TypeScriptFileModel) { + generated.P(`import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf";`) + generated.P(`import type { JsonValue, Registry } from "@bufbuild/protobuf";`) generated.P(`import { Server } from "@modelcontextprotocol/sdk/server/index.js";`) generated.P(`import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";`) - generated.P(`import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";`) + generated.P(`import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";`) + generated.P(`import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`) + generated.P(`import { Ajv2020 } from "ajv/dist/2020.js";`) imports := collectTypeScriptRenderImports(model) if len(imports) == 0 { @@ -136,10 +140,16 @@ func renderTypeScriptPrivateTypes(generated *protogen.GeneratedFile) { generated.P(" fileRegistry: unknown;") generated.P(" inputFileRegistry: unknown;") generated.P(" outputFileRegistry: unknown;") + generated.P(" registry: Registry;") generated.P(" annotations?: ToolAnnotationsMetadata;") generated.P(" icons: ToolIconMetadata[];") generated.P(" execution?: ToolExecutionMetadata;") generated.P("};") + generated.P() + generated.P("type ServerToolRegistry = {") + generated.P(" tools: RegisteredTool[];") + generated.P(" handlersInstalled: boolean;") + generated.P("};") } func renderTypeScriptService(generated *protogen.GeneratedFile, service TypeScriptServiceModel) { @@ -167,7 +177,8 @@ func renderTypeScriptService(generated *protogen.GeneratedFile, service TypeScri generated.P(" namespace?: string | null,") generated.P("): void {") generated.P(" const registry = getToolRegistry(server);") - generated.P(" registry.push(") + generated.P(" installMcpHandlers(server, registry);") + generated.P(" registry.tools.push(") for _, method := range service.Methods { renderTypeScriptRegisteredTool(generated, service, method) generated.P(" },") @@ -194,6 +205,7 @@ func renderTypeScriptRegisteredTool(generated *protogen.GeneratedFile, service T generated.P(" fileRegistry: ", method.Input.RegistryRef.RefName, ",") generated.P(" inputFileRegistry: ", method.Input.RegistryRef.RefName, ",") generated.P(" outputFileRegistry: ", method.Output.RegistryRef.RefName, ",") + generated.P(" registry: buildProtoRegistry(", method.Input.RegistryRef.RefName, ", ", method.Output.RegistryRef.RefName, "),") renderTypeScriptAnnotations(generated, method.Annotations) renderTypeScriptIcons(generated, method.Icons) renderTypeScriptExecution(generated, method.TaskSupport) @@ -255,17 +267,95 @@ func renderTypeScriptExecution(generated *protogen.GeneratedFile, taskSupport mc } func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { - generated.P("const toolRegistries = new WeakMap();") + generated.P("const toolRegistries = new WeakMap();") + generated.P("const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true });") generated.P() - generated.P("function getToolRegistry(server: Server): RegisteredTool[] {") + generated.P("function getToolRegistry(server: Server): ServerToolRegistry {") generated.P(" let registry = toolRegistries.get(server);") generated.P(" if (registry === undefined) {") - generated.P(" registry = [];") + generated.P(" registry = { tools: [], handlersInstalled: false };") generated.P(" toolRegistries.set(server, registry);") generated.P(" }") generated.P(" return registry;") generated.P("}") generated.P() + generated.P("function installMcpHandlers(server: Server, registry: ServerToolRegistry): void {") + generated.P(" if (registry.handlersInstalled) {") + generated.P(" return;") + generated.P(" }") + generated.P(` server.assertCanSetRequestHandler("tools/list");`) + generated.P(` server.assertCanSetRequestHandler("tools/call");`) + generated.P(" server.setRequestHandler(ListToolsRequestSchema, async (): Promise<{ tools: Tool[] }> => ({") + generated.P(" tools: listRegisteredTools(registry),") + generated.P(" }));") + generated.P(" server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => (") + generated.P(" dispatchToolCall(") + generated.P(" registry,") + generated.P(" request.params.name,") + generated.P(" request.params.arguments as JsonValue | undefined,") + generated.P(" extra as ToolRequestContext,") + generated.P(" )") + generated.P(" ));") + generated.P(" registry.handlersInstalled = true;") + generated.P("}") + generated.P() + generated.P("function listRegisteredTools(registry: ServerToolRegistry): Tool[] {") + generated.P(" return registry.tools.map(buildListTool);") + generated.P("}") + generated.P() + generated.P("function buildListTool(tool: RegisteredTool): Tool {") + generated.P(" const listed = {") + generated.P(" name: tool.name,") + generated.P(" inputSchema: loadSchema(tool.inputSchemaJson),") + generated.P(" outputSchema: loadSchema(tool.outputSchemaJson),") + generated.P(" } as Tool;") + generated.P(" if (tool.title !== undefined) {") + generated.P(" listed.title = tool.title;") + generated.P(" }") + generated.P(" if (tool.description !== undefined) {") + generated.P(" listed.description = tool.description;") + generated.P(" }") + generated.P(" if (tool.annotations !== undefined) {") + generated.P(" listed.annotations = tool.annotations;") + generated.P(" }") + generated.P(" if (tool.icons.length > 0) {") + generated.P(" listed.icons = tool.icons as Tool[\"icons\"];") + generated.P(" }") + generated.P(" if (tool.execution !== undefined) {") + generated.P(" listed.execution = tool.execution;") + generated.P(" }") + generated.P(" return listed;") + generated.P("}") + generated.P() + generated.P("function loadSchema(rawSchemaJson: string): Record {") + generated.P(" const parsed: unknown = JSON.parse(rawSchemaJson);") + generated.P(" if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) {") + generated.P(" throw new Error(\"mcpruntime: generated schema JSON must be an object\");") + generated.P(" }") + generated.P(" return parsed as Record;") + generated.P("}") + generated.P() + generated.P("async function dispatchToolCall(") + generated.P(" registry: ServerToolRegistry,") + generated.P(" toolName: string,") + generated.P(" _arguments: JsonValue | undefined,") + generated.P(" _extra: ToolRequestContext,") + generated.P("): Promise {") + generated.P(" const tool = registry.tools.find((candidate) => candidate.name === toolName);") + generated.P(" if (tool === undefined) {") + generated.P(" throw new McpError(ErrorCode.InvalidParams, `unknown tool '${toolName}'`);") + generated.P(" }") + generated.P(" void tool;") + generated.P(" void fromJson;") + generated.P(" void toJson;") + generated.P(" void jsonSchemaValidator;") + generated.P(" throw new McpError(ErrorCode.InvalidParams, `tool '${toolName}' dispatch is not implemented yet`);") + generated.P("}") + generated.P() + generated.P("function buildProtoRegistry(...fileRegistries: unknown[]): Registry {") + generated.P(" return createRegistry(...(fileRegistries as Parameters));") + generated.P("}") + generated.P() generated.P("function resolveToolName(") generated.P(" namespace: string | null | undefined,") generated.P(" defaultName: string,") diff --git a/internal/codegen/typescript_contract_test.go b/internal/codegen/typescript_contract_test.go index d75089c..ce01311 100644 --- a/internal/codegen/typescript_contract_test.go +++ b/internal/codegen/typescript_contract_test.go @@ -11,7 +11,7 @@ func TestTypeScriptContract_PublicAPIAndLowLevelImports(t *testing.T) { wantSnippets := []string{ `import { Server } from "@modelcontextprotocol/sdk/server/index.js";`, `import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";`, - `import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";`, + `import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`, `import type { CreateReportRequest, CreateReportResponse } from "./example_pb.js";`, `import { CreateReportRequestSchema, CreateReportResponseSchema, file_test_v1_example } from "./example_pb.js";`, "export type ToolRequestContext = RequestHandlerExtra;", From db30f2f4c8169959ab1bb725b709463b01bf7793 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 16:54:06 +0300 Subject: [PATCH 41/74] test(09-01): refresh TypeScript runtime list golden - update TypeScript golden with low-level handler installation - capture raw tools/list schema and metadata projection helpers --- testdata/golden/example_mcp.ts.golden | 104 ++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 5 deletions(-) diff --git a/testdata/golden/example_mcp.ts.golden b/testdata/golden/example_mcp.ts.golden index 0525268..2cb3a7f 100644 --- a/testdata/golden/example_mcp.ts.golden +++ b/testdata/golden/example_mcp.ts.golden @@ -1,9 +1,13 @@ // Code generated by protoc-gen-mcp. DO NOT EDIT. // source: internal/testproto/example/v1/example.proto +import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf"; +import type { JsonValue, Registry } from "@bufbuild/protobuf"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js"; +import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { Ajv2020 } from "ajv/dist/2020.js"; import type { CreateReportRequest, CreateReportResponse, DescribeAdvancedShapesRequest, DescribeAdvancedShapesResponse, DescribeScalarShapesRequest, DescribeScalarShapesResponse, HiddenThingRequest, HiddenThingResponse, PingRequest, PingResponse } from "./example_pb.js"; import { CreateReportRequestSchema, CreateReportResponseSchema, DescribeAdvancedShapesRequestSchema, DescribeAdvancedShapesResponseSchema, DescribeScalarShapesRequestSchema, DescribeScalarShapesResponseSchema, HiddenThingRequestSchema, HiddenThingResponseSchema, PingRequestSchema, PingResponseSchema, file_internal_testproto_example_v1_example } from "./example_pb.js"; @@ -42,11 +46,17 @@ type RegisteredTool = { fileRegistry: unknown; inputFileRegistry: unknown; outputFileRegistry: unknown; + registry: Registry; annotations?: ToolAnnotationsMetadata; icons: ToolIconMetadata[]; execution?: ToolExecutionMetadata; }; +type ServerToolRegistry = { + tools: RegisteredTool[]; + handlersInstalled: boolean; +}; + export interface ExampleAPIToolHandler { createReport( ctx: ToolRequestContext, @@ -91,7 +101,8 @@ export function registerExampleAPITools( namespace?: string | null, ): void { const registry = getToolRegistry(server); - registry.push( + installMcpHandlers(server, registry); + registry.tools.push( { name: resolveToolName(namespace, "CreateReport", "example"), title: "Create report", @@ -105,6 +116,7 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), annotations: undefined, icons: [ ], @@ -123,6 +135,7 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), annotations: undefined, icons: [ ], @@ -141,6 +154,7 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), annotations: undefined, icons: [ ], @@ -159,6 +173,7 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), annotations: undefined, icons: [ ], @@ -176,6 +191,7 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), annotations: undefined, icons: [ ], @@ -184,17 +200,95 @@ export function registerExampleAPITools( ); } -const toolRegistries = new WeakMap(); +const toolRegistries = new WeakMap(); +const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true }); -function getToolRegistry(server: Server): RegisteredTool[] { +function getToolRegistry(server: Server): ServerToolRegistry { let registry = toolRegistries.get(server); if (registry === undefined) { - registry = []; + registry = { tools: [], handlersInstalled: false }; toolRegistries.set(server, registry); } return registry; } +function installMcpHandlers(server: Server, registry: ServerToolRegistry): void { + if (registry.handlersInstalled) { + return; + } + server.assertCanSetRequestHandler("tools/list"); + server.assertCanSetRequestHandler("tools/call"); + server.setRequestHandler(ListToolsRequestSchema, async (): Promise<{ tools: Tool[] }> => ({ + tools: listRegisteredTools(registry), + })); + server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => ( + dispatchToolCall( + registry, + request.params.name, + request.params.arguments as JsonValue | undefined, + extra as ToolRequestContext, + ) + )); + registry.handlersInstalled = true; +} + +function listRegisteredTools(registry: ServerToolRegistry): Tool[] { + return registry.tools.map(buildListTool); +} + +function buildListTool(tool: RegisteredTool): Tool { + const listed = { + name: tool.name, + inputSchema: loadSchema(tool.inputSchemaJson), + outputSchema: loadSchema(tool.outputSchemaJson), + } as Tool; + if (tool.title !== undefined) { + listed.title = tool.title; + } + if (tool.description !== undefined) { + listed.description = tool.description; + } + if (tool.annotations !== undefined) { + listed.annotations = tool.annotations; + } + if (tool.icons.length > 0) { + listed.icons = tool.icons as Tool["icons"]; + } + if (tool.execution !== undefined) { + listed.execution = tool.execution; + } + return listed; +} + +function loadSchema(rawSchemaJson: string): Record { + const parsed: unknown = JSON.parse(rawSchemaJson); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("mcpruntime: generated schema JSON must be an object"); + } + return parsed as Record; +} + +async function dispatchToolCall( + registry: ServerToolRegistry, + toolName: string, + _arguments: JsonValue | undefined, + _extra: ToolRequestContext, +): Promise { + const tool = registry.tools.find((candidate) => candidate.name === toolName); + if (tool === undefined) { + throw new McpError(ErrorCode.InvalidParams, `unknown tool '${toolName}'`); + } + void tool; + void fromJson; + void toJson; + void jsonSchemaValidator; + throw new McpError(ErrorCode.InvalidParams, `tool '${toolName}' dispatch is not implemented yet`); +} + +function buildProtoRegistry(...fileRegistries: unknown[]): Registry { + return createRegistry(...(fileRegistries as Parameters)); +} + function resolveToolName( namespace: string | null | undefined, defaultName: string, From 5b391f81f9cd4cce1f18bf41c2c8e573f689c8ba Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 17:04:13 +0300 Subject: [PATCH 42/74] test(09-02): add TypeScript runtime contract red test - Add Node-executed generated runtime contract harness - Reuse local sdk-spike dependency checks for tsc/node execution --- internal/codegen/typescript_compile_test.go | 42 +-- internal/codegen/typescript_runtime_test.go | 279 ++++++++++++++++++++ 2 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 internal/codegen/typescript_runtime_test.go diff --git a/internal/codegen/typescript_compile_test.go b/internal/codegen/typescript_compile_test.go index 84f92f7..7cdb6bb 100644 --- a/internal/codegen/typescript_compile_test.go +++ b/internal/codegen/typescript_compile_test.go @@ -10,24 +10,7 @@ import ( ) func TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skipf("node is required for TypeScript compile smoke: %v", err) - } - if _, err := exec.LookPath("npm"); err != nil { - t.Skipf("npm is required for TypeScript compile smoke: %v", err) - } - - spikeDir, err := filepath.Abs(filepath.Join("..", "..", "examples", "node", "sdk-spike")) - if err != nil { - t.Fatalf("resolve examples/node/sdk-spike: %v", err) - } - if _, err := os.Stat(filepath.Join(spikeDir, "package.json")); err != nil { - t.Fatalf("examples/node/sdk-spike package.json is required: %v", err) - } - if _, err := os.Stat(filepath.Join(spikeDir, "node_modules", ".bin", "tsc")); err != nil { - t.Fatalf("local TypeScript compiler is required; run npm ci in examples/node/sdk-spike: %v", err) - } - + spikeDir := requireTypeScriptSDKSpike(t) plugin := newTempProtogenPlugin(t, map[string]string{ "compile/v1/public-api.proto": strings.Join([]string{ `syntax = "proto3";`, @@ -84,6 +67,29 @@ func TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext(t *testing.T) { } } +func requireTypeScriptSDKSpike(t *testing.T) string { + t.Helper() + + if _, err := exec.LookPath("node"); err != nil { + t.Skipf("node is required for TypeScript compile smoke: %v", err) + } + if _, err := exec.LookPath("npm"); err != nil { + t.Skipf("npm is required for TypeScript compile smoke: %v", err) + } + + spikeDir, err := filepath.Abs(filepath.Join("..", "..", "examples", "node", "sdk-spike")) + if err != nil { + t.Fatalf("resolve examples/node/sdk-spike: %v", err) + } + if _, err := os.Stat(filepath.Join(spikeDir, "package.json")); err != nil { + t.Fatalf("examples/node/sdk-spike package.json is required: %v", err) + } + if _, err := os.Stat(filepath.Join(spikeDir, "node_modules", ".bin", "tsc")); err != nil { + t.Fatalf("local TypeScript compiler is required; run npm ci in examples/node/sdk-spike: %v", err) + } + return spikeDir +} + func writeTypeScriptFixtureFile(t *testing.T, path string, contents []byte) { t.Helper() diff --git a/internal/codegen/typescript_runtime_test.go b/internal/codegen/typescript_runtime_test.go new file mode 100644 index 0000000..fb9c0ca --- /dev/null +++ b/internal/codegen/typescript_runtime_test.go @@ -0,0 +1,279 @@ +package codegen + +import ( + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/bufbuild/protocompile/protoutil" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" +) + +func TestTypeScriptRuntimeContract(t *testing.T) { + spikeDir := requireTypeScriptSDKSpike(t) + const protoPath = "runtime/v1/runtime-contract.proto" + plugin := newTempProtogenPlugin(t, map[string]string{ + protoPath: strings.Join([]string{ + `syntax = "proto3";`, + `package runtime.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/runtime;runtimev1";`, + `service RuntimeAPI {`, + ` rpc Echo(EchoRequest) returns (EchoResponse);`, + `}`, + `message EchoRequest {`, + ` string label = 1;`, + ` int32 count = 2;`, + ` bytes payload = 3;`, + `}`, + `message EchoResponse {`, + ` string label = 1;`, + ` int32 count = 2;`, + ` bytes payload = 3;`, + `}`, + ``, + }, "\n"), + }, protoPath) + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate TypeScript: %v", err) + } + + tempProject, err := os.MkdirTemp(spikeDir, ".tmp-typescript-runtime-*") + if err != nil { + t.Fatalf("create temporary TypeScript runtime project under sdk-spike: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tempProject); err != nil { + t.Errorf("remove temporary TypeScript runtime project: %v", err) + } + }) + + sourceDir := filepath.Join(tempProject, "runtime", "v1") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("create runtime fixture source dir: %v", err) + } + + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "runtime_contract_mcp.ts"), generatedFileContent(t, plugin, "runtime/v1/runtime_contract_mcp.ts")) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "runtime_contract_pb.ts"), []byte(protobufESRuntimeFixtureModule(t, plugin, protoPath))) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "runtime.ts"), []byte(typeScriptRuntimeContractFixture())) + writeTypeScriptFixtureFile(t, filepath.Join(tempProject, "tsconfig.json"), []byte(typeScriptRuntimeFixtureTSConfig())) + + compile := exec.Command("npm", "exec", "--prefix", spikeDir, "--", "tsc", "-p", filepath.Join(tempProject, "tsconfig.json")) + compile.Dir = spikeDir + compile.Env = append(os.Environ(), "NO_COLOR=1") + compileOutput, err := compile.CombinedOutput() + if err != nil { + t.Fatalf("generated TypeScript runtime contract failed NodeNext compile via npm exec:\n%s", string(compileOutput)) + } + + run := exec.Command("node", filepath.Join(tempProject, "dist", "runtime", "v1", "runtime.js")) + run.Dir = spikeDir + run.Env = append(os.Environ(), "NO_COLOR=1") + runOutput, err := run.CombinedOutput() + if err != nil { + t.Fatalf("generated TypeScript runtime contract failed under node:\n%s", string(runOutput)) + } +} + +func protobufESRuntimeFixtureModule(t *testing.T, plugin *protogen.Plugin, protoPath string) string { + t.Helper() + + var descriptorBase64 string + for _, file := range plugin.Files { + if file.Desc.Path() != protoPath { + continue + } + descriptorBytes, err := proto.Marshal(protoutil.ProtoFromFileDescriptor(file.Desc)) + if err != nil { + t.Fatalf("marshal %s descriptor proto: %v", protoPath, err) + } + descriptorBase64 = base64.StdEncoding.EncodeToString(descriptorBytes) + break + } + if descriptorBase64 == "" { + t.Fatalf("proto descriptor %q not found in generated plugin", protoPath) + } + + return fmt.Sprintf(` +import type { Message } from "@bufbuild/protobuf"; +import { fileDesc, messageDesc, type GenFile, type GenMessage } from "@bufbuild/protobuf/codegenv2"; + +export const file_runtime_v1_runtime_contract: GenFile = fileDesc(%q); + +export type EchoRequest = Message<"runtime.v1.EchoRequest"> & { + label: string; + count: number; + payload: Uint8Array; +}; + +export type EchoResponse = Message<"runtime.v1.EchoResponse"> & { + label: string; + count: number; + payload: Uint8Array; +}; + +export const EchoRequestSchema: GenMessage = messageDesc(file_runtime_v1_runtime_contract, 0); +export const EchoResponseSchema: GenMessage = messageDesc(file_runtime_v1_runtime_contract, 1); +`, descriptorBase64) +} + +func typeScriptRuntimeContractFixture() string { + return ` +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; +import { registerRuntimeAPITools, type RuntimeAPIToolHandler } from "./runtime_contract_mcp.js"; +import type { EchoRequest, EchoResponse } from "./runtime_contract_pb.js"; + +type TestToolResult = { + content: { type: "text"; text: string }[]; + structuredContent?: Record; + isError?: boolean; +}; + +let handlerCalls = 0; +let mode: "ok" | "business-error" | "bad-output" = "ok"; + +const handler: RuntimeAPIToolHandler = { + echo(_ctx, request: EchoRequest): EchoResponse { + handlerCalls += 1; + assert.equal(request.$typeName, "runtime.v1.EchoRequest"); + assert.ok(request.payload instanceof Uint8Array); + if (mode === "business-error") { + throw new Error("business failed"); + } + if (mode === "bad-output") { + return { + $typeName: "runtime.v1.EchoResponse", + label: 123 as unknown as string, + count: request.count, + payload: request.payload, + }; + } + return { + $typeName: "runtime.v1.EchoResponse", + label: request.label.toUpperCase(), + count: request.count + 1, + payload: request.payload, + }; + }, +}; + +const server = new Server( + { name: "generated-typescript-runtime", version: "0.0.0" }, + { capabilities: { tools: {} } }, +); +registerRuntimeAPITools(server, handler, ""); + +const client = new Client( + { name: "generated-typescript-runtime-client", version: "0.0.0" }, + { capabilities: {} }, +); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +const listed = await client.listTools(); +assert.equal(listed.tools.length, 1); +assert.equal(listed.tools[0]?.name, "Echo"); +assert.equal(listed.tools[0]?.inputSchema.type, "object"); +assert.deepEqual(listed.tools[0]?.inputSchema.required, ["label", "count", "payload"]); +assert.equal(listed.tools[0]?.outputSchema?.type, "object"); + +const valid = await client.callTool({ + name: "Echo", + arguments: { label: "alpha", count: 41, payload: "AQID" }, +}) as TestToolResult; +assert.equal(handlerCalls, 1); +assert.equal(valid.isError, undefined); +assert.deepEqual(valid.structuredContent, { label: "ALPHA", count: 42, payload: "AQID" }); +assert.equal(valid.content.length, 1); +assert.deepEqual(JSON.parse(valid.content[0]?.text ?? ""), valid.structuredContent); + +await expectInvalidParams( + () => client.callTool({ name: "Echo", arguments: { label: 7, count: 1, payload: "AQID" } }), + /invalid arguments for tool 'Echo'/, +); +assert.equal(handlerCalls, 1); + +await expectInvalidParams( + () => client.callTool({ name: "Echo", arguments: { label: "alpha", count: 1, payload: "%" } }), + /invalid arguments for tool 'Echo'/, +); +assert.equal(handlerCalls, 1); + +mode = "business-error"; +const toolError = await client.callTool({ + name: "Echo", + arguments: { label: "alpha", count: 1, payload: "AQID" }, +}) as TestToolResult; +assert.equal(handlerCalls, 2); +assert.equal(toolError.isError, true); +assert.match(toolError.content[0]?.text ?? "", /business failed/); + +mode = "bad-output"; +await expectRuntimeBug( + () => client.callTool({ name: "Echo", arguments: { label: "alpha", count: 1, payload: "AQID" } }), + /mcpruntime:/, +); +assert.equal(handlerCalls, 3); + +await client.close(); +await server.close(); + +async function expectInvalidParams(run: () => Promise, message: RegExp): Promise { + try { + await run(); + } catch (error) { + if (!(error instanceof McpError)) { + assert.fail("expected McpError, got " + String(error)); + } + assert.equal(error.code, ErrorCode.InvalidParams); + assert.match(error.message, message); + return; + } + assert.fail("expected InvalidParams error"); +} + +async function expectRuntimeBug(run: () => Promise, message: RegExp): Promise { + try { + await run(); + } catch (error) { + if (!(error instanceof Error)) { + assert.fail("expected Error, got " + String(error)); + } + assert.match(error.message, message); + return; + } + assert.fail("expected runtime bug error"); +} +` +} + +func typeScriptRuntimeFixtureTSConfig() string { + return ` +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "rootDir": ".", + "outDir": "dist" + }, + "include": [ + "runtime/v1/runtime_contract_mcp.ts", + "runtime/v1/runtime_contract_pb.ts", + "runtime/v1/runtime.ts" + ] +} +` +} From 9bdd8f450f6397b5aa0f5ca0b8e2e26e36b55df2 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 17:08:25 +0300 Subject: [PATCH 43/74] feat(09-02): implement TypeScript tools call dispatch - Validate generated tool input and output with per-tool Ajv validators - Parse and serialize tool messages through Protobuf-ES with registries - Map invalid params, handler errors, and runtime output bugs separately --- internal/codegen/render_typescript.go | 121 ++++++++++++++++---- internal/codegen/typescript_compile_test.go | 32 ++---- internal/codegen/typescript_runtime_test.go | 38 +++--- 3 files changed, 128 insertions(+), 63 deletions(-) diff --git a/internal/codegen/render_typescript.go b/internal/codegen/render_typescript.go index 5656c42..1a95caf 100644 --- a/internal/codegen/render_typescript.go +++ b/internal/codegen/render_typescript.go @@ -29,7 +29,7 @@ func renderTypeScriptFile(plugin *protogen.Plugin, model TypeScriptFileModel) er generated.P() for _, service := range model.Services { - renderTypeScriptService(generated, service) + renderTypeScriptService(generated, model, service) generated.P() } @@ -39,12 +39,15 @@ func renderTypeScriptFile(plugin *protogen.Plugin, model TypeScriptFileModel) er func renderTypeScriptImports(generated *protogen.GeneratedFile, model TypeScriptFileModel) { generated.P(`import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf";`) + generated.P(`import type { DescFile, DescMessage, MessageShape } from "@bufbuild/protobuf";`) generated.P(`import type { JsonValue, Registry } from "@bufbuild/protobuf";`) generated.P(`import { Server } from "@modelcontextprotocol/sdk/server/index.js";`) generated.P(`import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";`) generated.P(`import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";`) + generated.P(`import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";`) generated.P(`import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`) generated.P(`import { Ajv2020 } from "ajv/dist/2020.js";`) + generated.P(`import type { ValidateFunction } from "ajv/dist/2020.js";`) imports := collectTypeScriptRenderImports(model) if len(imports) == 0 { @@ -132,15 +135,17 @@ func renderTypeScriptPrivateTypes(generated *protogen.GeneratedFile) { generated.P(" title?: string;") generated.P(" description?: string;") generated.P(" impl: unknown;") - generated.P(" handler: unknown;") + generated.P(" handler: (ctx: ToolRequestContext, request: never) => unknown | Promise;") generated.P(" inputSchemaJson: string;") generated.P(" outputSchemaJson: string;") - generated.P(" inputSchema: unknown;") - generated.P(" outputSchema: unknown;") - generated.P(" fileRegistry: unknown;") - generated.P(" inputFileRegistry: unknown;") - generated.P(" outputFileRegistry: unknown;") + generated.P(" inputSchema: DescMessage;") + generated.P(" outputSchema: DescMessage;") + generated.P(" fileRegistry: DescFile;") + generated.P(" inputFileRegistry: DescFile;") + generated.P(" outputFileRegistry: DescFile;") generated.P(" registry: Registry;") + generated.P(" validateInput: ValidateFunction;") + generated.P(" validateOutput: ValidateFunction;") generated.P(" annotations?: ToolAnnotationsMetadata;") generated.P(" icons: ToolIconMetadata[];") generated.P(" execution?: ToolExecutionMetadata;") @@ -152,7 +157,7 @@ func renderTypeScriptPrivateTypes(generated *protogen.GeneratedFile) { generated.P("};") } -func renderTypeScriptService(generated *protogen.GeneratedFile, service TypeScriptServiceModel) { +func renderTypeScriptService(generated *protogen.GeneratedFile, model TypeScriptFileModel, service TypeScriptServiceModel) { // Public declarations render as `export interface ExampleAPIToolHandler` // and `export function registerExampleAPITools` for Node consumers. generated.P("export interface ", service.HandlerName, " {") @@ -180,14 +185,14 @@ func renderTypeScriptService(generated *protogen.GeneratedFile, service TypeScri generated.P(" installMcpHandlers(server, registry);") generated.P(" registry.tools.push(") for _, method := range service.Methods { - renderTypeScriptRegisteredTool(generated, service, method) + renderTypeScriptRegisteredTool(generated, model, service, method) generated.P(" },") } generated.P(" );") generated.P("}") } -func renderTypeScriptRegisteredTool(generated *protogen.GeneratedFile, service TypeScriptServiceModel, method TypeScriptMethodModel) { +func renderTypeScriptRegisteredTool(generated *protogen.GeneratedFile, model TypeScriptFileModel, service TypeScriptServiceModel, method TypeScriptMethodModel) { generated.P(" {") generated.P(" name: resolveToolName(namespace, ", typescriptStringLiteral(method.ToolName), ", ", typescriptStringLiteral(service.Namespace), "),") if method.Title != "" { @@ -202,10 +207,12 @@ func renderTypeScriptRegisteredTool(generated *protogen.GeneratedFile, service T generated.P(" outputSchemaJson: ", method.SchemaConst, "_OUTPUT_SCHEMA_JSON,") generated.P(" inputSchema: ", method.Input.SchemaName, ",") generated.P(" outputSchema: ", method.Output.SchemaName, ",") - generated.P(" fileRegistry: ", method.Input.RegistryRef.RefName, ",") + generated.P(" fileRegistry: ", model.CurrentFile.RegistryRef.RefName, ",") generated.P(" inputFileRegistry: ", method.Input.RegistryRef.RefName, ",") generated.P(" outputFileRegistry: ", method.Output.RegistryRef.RefName, ",") - generated.P(" registry: buildProtoRegistry(", method.Input.RegistryRef.RefName, ", ", method.Output.RegistryRef.RefName, "),") + generated.P(" registry: buildProtoRegistry(", model.CurrentFile.RegistryRef.RefName, ", ", method.Input.RegistryRef.RefName, ", ", method.Output.RegistryRef.RefName, "),") + generated.P(" validateInput: compileSchema(", method.SchemaConst, "_INPUT_SCHEMA_JSON),") + generated.P(" validateOutput: compileSchema(", method.SchemaConst, "_OUTPUT_SCHEMA_JSON),") renderTypeScriptAnnotations(generated, method.Annotations) renderTypeScriptIcons(generated, method.Icons) renderTypeScriptExecution(generated, method.TaskSupport) @@ -291,8 +298,7 @@ func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { generated.P(" server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => (") generated.P(" dispatchToolCall(") generated.P(" registry,") - generated.P(" request.params.name,") - generated.P(" request.params.arguments as JsonValue | undefined,") + generated.P(" request,") generated.P(" extra as ToolRequestContext,") generated.P(" )") generated.P(" ));") @@ -335,25 +341,92 @@ func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { generated.P(" return parsed as Record;") generated.P("}") generated.P() + generated.P("function compileSchema(rawSchemaJson: string): ValidateFunction {") + generated.P(" return jsonSchemaValidator.compile(loadSchema(rawSchemaJson));") + generated.P("}") + generated.P() + generated.P("function assertValid(") + generated.P(" toolName: string,") + generated.P(` phase: "input" | "output",`) + generated.P(" validate: ValidateFunction,") + generated.P(" value: unknown,") + generated.P("): void {") + generated.P(" if (validate(value)) {") + generated.P(" return;") + generated.P(" }") + generated.P(" const message = jsonSchemaValidator.errorsText(validate.errors);") + generated.P(` if (phase === "input") {`) + generated.P(" invalidParams(toolName, message);") + generated.P(" }") + generated.P(" throw new Error(`mcpruntime: validate output for tool '${toolName}': ${message}`);") + generated.P("}") + generated.P() + generated.P("function invalidParams(toolName: string, error: unknown): never {") + generated.P(" throw new McpError(") + generated.P(" ErrorCode.InvalidParams,") + generated.P(" `invalid arguments for tool '${toolName}': ${errorMessage(error)}`,") + generated.P(" );") + generated.P("}") + generated.P() + generated.P("function toolErrorResult(error: unknown): CallToolResult {") + generated.P(" return {") + generated.P(` content: [{ type: "text", text: errorMessage(error) }],`) + generated.P(" isError: true,") + generated.P(" };") + generated.P("}") + generated.P() generated.P("async function dispatchToolCall(") generated.P(" registry: ServerToolRegistry,") - generated.P(" toolName: string,") - generated.P(" _arguments: JsonValue | undefined,") - generated.P(" _extra: ToolRequestContext,") + generated.P(" request: CallToolRequest,") + generated.P(" extra: ToolRequestContext,") generated.P("): Promise {") + generated.P(" const toolName = request.params.name;") generated.P(" const tool = registry.tools.find((candidate) => candidate.name === toolName);") generated.P(" if (tool === undefined) {") generated.P(" throw new McpError(ErrorCode.InvalidParams, `unknown tool '${toolName}'`);") generated.P(" }") - generated.P(" void tool;") - generated.P(" void fromJson;") - generated.P(" void toJson;") - generated.P(" void jsonSchemaValidator;") - generated.P(" throw new McpError(ErrorCode.InvalidParams, `tool '${toolName}' dispatch is not implemented yet`);") + generated.P(" const args = (request.params.arguments ?? {}) as JsonValue;") + generated.P(" assertValid(tool.name, \"input\", tool.validateInput, args);") + generated.P() + generated.P(" let parsedRequest: MessageShape;") + generated.P(" try {") + generated.P(" parsedRequest = fromJson(tool.inputSchema, args, { registry: tool.registry });") + generated.P(" } catch (error) {") + generated.P(" invalidParams(tool.name, error);") + generated.P(" }") + generated.P() + generated.P(" let response: unknown;") + generated.P(" try {") + generated.P(" response = await tool.handler(extra, parsedRequest as never);") + generated.P(" } catch (error) {") + generated.P(" return toolErrorResult(error);") + generated.P(" }") + generated.P() + generated.P(" let payload: JsonValue;") + generated.P(" try {") + generated.P(" payload = toJson(tool.outputSchema, response as MessageShape, {") + generated.P(" alwaysEmitImplicit: true,") + generated.P(" registry: tool.registry,") + generated.P(" }) as JsonValue;") + generated.P(" } catch (error) {") + generated.P(" throw new Error(`mcpruntime: marshal output for tool '${tool.name}': ${errorMessage(error)}`);") + generated.P(" }") + generated.P(" assertValid(tool.name, \"output\", tool.validateOutput, payload);") + generated.P(" return {") + generated.P(` content: [{ type: "text", text: JSON.stringify(payload) }],`) + generated.P(" structuredContent: payload as Record,") + generated.P(" };") + generated.P("}") + generated.P() + generated.P("function errorMessage(error: unknown): string {") + generated.P(` if (error instanceof Error && error.message !== "") {`) + generated.P(" return error.message;") + generated.P(" }") + generated.P(" return String(error);") generated.P("}") generated.P() - generated.P("function buildProtoRegistry(...fileRegistries: unknown[]): Registry {") - generated.P(" return createRegistry(...(fileRegistries as Parameters));") + generated.P("function buildProtoRegistry(...fileRegistries: DescFile[]): Registry {") + generated.P(" return createRegistry(...fileRegistries);") generated.P("}") generated.P() generated.P("function resolveToolName(") diff --git a/internal/codegen/typescript_compile_test.go b/internal/codegen/typescript_compile_test.go index 7cdb6bb..f42db33 100644 --- a/internal/codegen/typescript_compile_test.go +++ b/internal/codegen/typescript_compile_test.go @@ -54,7 +54,7 @@ func TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext(t *testing.T) { generatedSidecar := generatedFileContent(t, plugin, "compile/v1/public_api_mcp.ts") writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "public_api_mcp.ts"), generatedSidecar) - writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "public_api_pb.ts"), []byte(protobufESCompileFixtureModule())) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "public_api_pb.ts"), []byte(protobufESCompileFixtureModule(protobufESFileDescriptorBase64(t, plugin, "compile/v1/public-api.proto")))) writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "usage.ts"), []byte(typeScriptPublicAPIUsageFixture())) writeTypeScriptFixtureFile(t, filepath.Join(tempProject, "tsconfig.json"), []byte(typeScriptCompileFixtureTSConfig())) @@ -98,9 +98,12 @@ func writeTypeScriptFixtureFile(t *testing.T, path string, contents []byte) { } } -func protobufESCompileFixtureModule() string { +func protobufESCompileFixtureModule(descriptorBase64 string) string { return ` import type { Message } from "@bufbuild/protobuf"; +import { fileDesc, messageDesc, type GenFile, type GenMessage } from "@bufbuild/protobuf/codegenv2"; + +export const file_compile_v1_public_api: GenFile = fileDesc("` + descriptorBase64 + `"); export type RenderRequest = Message<"compile.v1.RenderRequest"> & { label: string; @@ -118,27 +121,10 @@ export type Render_NestedResponse = Message<"compile.v1.Render.NestedResponse"> output: string; }; -export const RenderRequestSchema = { - typeName: "compile.v1.RenderRequest", -}; - -export const RenderResponseSchema = { - typeName: "compile.v1.RenderResponse", -}; - -export const Render_NestedRequestSchema = { - typeName: "compile.v1.Render.NestedRequest", -}; - -export const Render_NestedResponseSchema = { - typeName: "compile.v1.Render.NestedResponse", -}; - -export const file_compile_v1_public_api = { - proto: { - name: "compile/v1/public-api.proto", - }, -}; +export const Render_NestedRequestSchema: GenMessage = messageDesc(file_compile_v1_public_api, 0, 0); +export const Render_NestedResponseSchema: GenMessage = messageDesc(file_compile_v1_public_api, 0, 1); +export const RenderRequestSchema: GenMessage = messageDesc(file_compile_v1_public_api, 1); +export const RenderResponseSchema: GenMessage = messageDesc(file_compile_v1_public_api, 2); ` } diff --git a/internal/codegen/typescript_runtime_test.go b/internal/codegen/typescript_runtime_test.go index fb9c0ca..497f201 100644 --- a/internal/codegen/typescript_runtime_test.go +++ b/internal/codegen/typescript_runtime_test.go @@ -82,22 +82,7 @@ func TestTypeScriptRuntimeContract(t *testing.T) { func protobufESRuntimeFixtureModule(t *testing.T, plugin *protogen.Plugin, protoPath string) string { t.Helper() - var descriptorBase64 string - for _, file := range plugin.Files { - if file.Desc.Path() != protoPath { - continue - } - descriptorBytes, err := proto.Marshal(protoutil.ProtoFromFileDescriptor(file.Desc)) - if err != nil { - t.Fatalf("marshal %s descriptor proto: %v", protoPath, err) - } - descriptorBase64 = base64.StdEncoding.EncodeToString(descriptorBytes) - break - } - if descriptorBase64 == "" { - t.Fatalf("proto descriptor %q not found in generated plugin", protoPath) - } - + descriptorBase64 := protobufESFileDescriptorBase64(t, plugin, protoPath) return fmt.Sprintf(` import type { Message } from "@bufbuild/protobuf"; import { fileDesc, messageDesc, type GenFile, type GenMessage } from "@bufbuild/protobuf/codegenv2"; @@ -121,6 +106,27 @@ export const EchoResponseSchema: GenMessage = messageDesc(file_run `, descriptorBase64) } +func protobufESFileDescriptorBase64(t *testing.T, plugin *protogen.Plugin, protoPath string) string { + t.Helper() + + var descriptorBase64 string + for _, file := range plugin.Files { + if file.Desc.Path() != protoPath { + continue + } + descriptorBytes, err := proto.Marshal(protoutil.ProtoFromFileDescriptor(file.Desc)) + if err != nil { + t.Fatalf("marshal %s descriptor proto: %v", protoPath, err) + } + descriptorBase64 = base64.StdEncoding.EncodeToString(descriptorBytes) + break + } + if descriptorBase64 == "" { + t.Fatalf("proto descriptor %q not found in generated plugin", protoPath) + } + return descriptorBase64 +} + func typeScriptRuntimeContractFixture() string { return ` import assert from "node:assert/strict"; From 043b683b3495b0bbc1c6807d62dab6c7c6d686c8 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 17:10:39 +0300 Subject: [PATCH 44/74] test(09-02): refresh TypeScript dispatch contracts - Assert generated Ajv, Protobuf-ES, and structured output dispatch snippets - Refresh TypeScript golden snapshot for complete tools/call runtime --- internal/codegen/generator_test.go | 12 ++ internal/codegen/typescript_contract_test.go | 21 +++ testdata/golden/example_mcp.ts.golden | 127 +++++++++++++++---- 3 files changed, 137 insertions(+), 23 deletions(-) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 83c771c..067ae8d 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -157,15 +157,27 @@ func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { "export const EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON", "export const EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON", `import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf";`, + `import type { DescFile, DescMessage, MessageShape } from "@bufbuild/protobuf";`, `import type { JsonValue, Registry } from "@bufbuild/protobuf";`, `import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";`, + `import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";`, `import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`, `import { Ajv2020 } from "ajv/dist/2020.js";`, + `import type { ValidateFunction } from "ajv/dist/2020.js";`, + "const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true });", "type ServerToolRegistry = {", "function installMcpHandlers(server: Server, registry: ServerToolRegistry): void {", "function listRegisteredTools(registry: ServerToolRegistry): Tool[] {", "function buildListTool(tool: RegisteredTool): Tool {", "function loadSchema(rawSchemaJson: string): Record {", + "function compileSchema(rawSchemaJson: string): ValidateFunction {", + "function assertValid(", + "fromJson(tool.inputSchema, args, { registry: tool.registry })", + "toJson(tool.outputSchema, response as MessageShape, {", + "alwaysEmitImplicit: true", + "structuredContent: payload as Record", + "new McpError(ErrorCode.InvalidParams", + "mcpruntime: validate output for tool", } for _, snippet := range wantSnippets { if !strings.Contains(generated, snippet) { diff --git a/internal/codegen/typescript_contract_test.go b/internal/codegen/typescript_contract_test.go index ce01311..fae706f 100644 --- a/internal/codegen/typescript_contract_test.go +++ b/internal/codegen/typescript_contract_test.go @@ -89,15 +89,36 @@ func TestTypeScriptContract_RuntimeDispatchPaths(t *testing.T) { wantSnippets := []string{ `import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf";`, + `import type { DescFile, DescMessage, MessageShape } from "@bufbuild/protobuf";`, `import type { JsonValue, Registry } from "@bufbuild/protobuf";`, `import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";`, + `import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";`, `import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`, `import { Ajv2020 } from "ajv/dist/2020.js";`, + `import type { ValidateFunction } from "ajv/dist/2020.js";`, + "const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true });", "type ServerToolRegistry = {", "function installMcpHandlers(server: Server, registry: ServerToolRegistry): void {", "function listRegisteredTools(registry: ServerToolRegistry): Tool[] {", "function buildListTool(tool: RegisteredTool): Tool {", "function loadSchema(rawSchemaJson: string): Record {", + "function compileSchema(rawSchemaJson: string): ValidateFunction {", + "function assertValid(", + "function invalidParams(toolName: string, error: unknown): never {", + "new McpError(ErrorCode.InvalidParams", + "throw new McpError(", + " ErrorCode.InvalidParams,", + "function toolErrorResult(error: unknown): CallToolResult {", + "async function dispatchToolCall(", + "request: CallToolRequest,", + "assertValid(tool.name, \"input\", tool.validateInput, args);", + "fromJson(tool.inputSchema, args, { registry: tool.registry })", + "toJson(tool.outputSchema, response as MessageShape, {", + "alwaysEmitImplicit: true", + "assertValid(tool.name, \"output\", tool.validateOutput, payload);", + "structuredContent: payload as Record", + "mcpruntime: validate output for tool", + "mcpruntime: marshal output for tool", } assertTypeScriptContains(t, generated, wantSnippets...) diff --git a/testdata/golden/example_mcp.ts.golden b/testdata/golden/example_mcp.ts.golden index 2cb3a7f..2ca56c9 100644 --- a/testdata/golden/example_mcp.ts.golden +++ b/testdata/golden/example_mcp.ts.golden @@ -2,12 +2,15 @@ // source: internal/testproto/example/v1/example.proto import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf"; +import type { DescFile, DescMessage, MessageShape } from "@bufbuild/protobuf"; import type { JsonValue, Registry } from "@bufbuild/protobuf"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js"; import { Ajv2020 } from "ajv/dist/2020.js"; +import type { ValidateFunction } from "ajv/dist/2020.js"; import type { CreateReportRequest, CreateReportResponse, DescribeAdvancedShapesRequest, DescribeAdvancedShapesResponse, DescribeScalarShapesRequest, DescribeScalarShapesResponse, HiddenThingRequest, HiddenThingResponse, PingRequest, PingResponse } from "./example_pb.js"; import { CreateReportRequestSchema, CreateReportResponseSchema, DescribeAdvancedShapesRequestSchema, DescribeAdvancedShapesResponseSchema, DescribeScalarShapesRequestSchema, DescribeScalarShapesResponseSchema, HiddenThingRequestSchema, HiddenThingResponseSchema, PingRequestSchema, PingResponseSchema, file_internal_testproto_example_v1_example } from "./example_pb.js"; @@ -38,15 +41,17 @@ type RegisteredTool = { title?: string; description?: string; impl: unknown; - handler: unknown; + handler: (ctx: ToolRequestContext, request: never) => unknown | Promise; inputSchemaJson: string; outputSchemaJson: string; - inputSchema: unknown; - outputSchema: unknown; - fileRegistry: unknown; - inputFileRegistry: unknown; - outputFileRegistry: unknown; + inputSchema: DescMessage; + outputSchema: DescMessage; + fileRegistry: DescFile; + inputFileRegistry: DescFile; + outputFileRegistry: DescFile; registry: Registry; + validateInput: ValidateFunction; + validateOutput: ValidateFunction; annotations?: ToolAnnotationsMetadata; icons: ToolIconMetadata[]; execution?: ToolExecutionMetadata; @@ -116,7 +121,9 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, - registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + validateInput: compileSchema(EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON), + validateOutput: compileSchema(EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON), annotations: undefined, icons: [ ], @@ -135,7 +142,9 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, - registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + validateInput: compileSchema(EXAMPLE_API_PING_INPUT_SCHEMA_JSON), + validateOutput: compileSchema(EXAMPLE_API_PING_OUTPUT_SCHEMA_JSON), annotations: undefined, icons: [ ], @@ -154,7 +163,9 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, - registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + validateInput: compileSchema(EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_INPUT_SCHEMA_JSON), + validateOutput: compileSchema(EXAMPLE_API_DESCRIBE_ADVANCED_SHAPES_OUTPUT_SCHEMA_JSON), annotations: undefined, icons: [ ], @@ -173,7 +184,9 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, - registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + validateInput: compileSchema(EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON), + validateOutput: compileSchema(EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON), annotations: undefined, icons: [ ], @@ -191,7 +204,9 @@ export function registerExampleAPITools( fileRegistry: file_internal_testproto_example_v1_example, inputFileRegistry: file_internal_testproto_example_v1_example, outputFileRegistry: file_internal_testproto_example_v1_example, - registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + registry: buildProtoRegistry(file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example, file_internal_testproto_example_v1_example), + validateInput: compileSchema(EXAMPLE_API_HIDDEN_THING_INPUT_SCHEMA_JSON), + validateOutput: compileSchema(EXAMPLE_API_HIDDEN_THING_OUTPUT_SCHEMA_JSON), annotations: undefined, icons: [ ], @@ -224,8 +239,7 @@ function installMcpHandlers(server: Server, registry: ServerToolRegistry): void server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => ( dispatchToolCall( registry, - request.params.name, - request.params.arguments as JsonValue | undefined, + request, extra as ToolRequestContext, ) )); @@ -268,25 +282,92 @@ function loadSchema(rawSchemaJson: string): Record { return parsed as Record; } +function compileSchema(rawSchemaJson: string): ValidateFunction { + return jsonSchemaValidator.compile(loadSchema(rawSchemaJson)); +} + +function assertValid( + toolName: string, + phase: "input" | "output", + validate: ValidateFunction, + value: unknown, +): void { + if (validate(value)) { + return; + } + const message = jsonSchemaValidator.errorsText(validate.errors); + if (phase === "input") { + invalidParams(toolName, message); + } + throw new Error(`mcpruntime: validate output for tool '${toolName}': ${message}`); +} + +function invalidParams(toolName: string, error: unknown): never { + throw new McpError( + ErrorCode.InvalidParams, + `invalid arguments for tool '${toolName}': ${errorMessage(error)}`, + ); +} + +function toolErrorResult(error: unknown): CallToolResult { + return { + content: [{ type: "text", text: errorMessage(error) }], + isError: true, + }; +} + async function dispatchToolCall( registry: ServerToolRegistry, - toolName: string, - _arguments: JsonValue | undefined, - _extra: ToolRequestContext, + request: CallToolRequest, + extra: ToolRequestContext, ): Promise { + const toolName = request.params.name; const tool = registry.tools.find((candidate) => candidate.name === toolName); if (tool === undefined) { throw new McpError(ErrorCode.InvalidParams, `unknown tool '${toolName}'`); } - void tool; - void fromJson; - void toJson; - void jsonSchemaValidator; - throw new McpError(ErrorCode.InvalidParams, `tool '${toolName}' dispatch is not implemented yet`); + const args = (request.params.arguments ?? {}) as JsonValue; + assertValid(tool.name, "input", tool.validateInput, args); + + let parsedRequest: MessageShape; + try { + parsedRequest = fromJson(tool.inputSchema, args, { registry: tool.registry }); + } catch (error) { + invalidParams(tool.name, error); + } + + let response: unknown; + try { + response = await tool.handler(extra, parsedRequest as never); + } catch (error) { + return toolErrorResult(error); + } + + let payload: JsonValue; + try { + payload = toJson(tool.outputSchema, response as MessageShape, { + alwaysEmitImplicit: true, + registry: tool.registry, + }) as JsonValue; + } catch (error) { + throw new Error(`mcpruntime: marshal output for tool '${tool.name}': ${errorMessage(error)}`); + } + assertValid(tool.name, "output", tool.validateOutput, payload); + return { + content: [{ type: "text", text: JSON.stringify(payload) }], + structuredContent: payload as Record, + }; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message !== "") { + return error.message; + } + return String(error); } -function buildProtoRegistry(...fileRegistries: unknown[]): Registry { - return createRegistry(...(fileRegistries as Parameters)); +function buildProtoRegistry(...fileRegistries: DescFile[]): Registry { + return createRegistry(...fileRegistries); } function resolveToolName( From e634c471f0a63a0db34780c80133d5d0a910b0f5 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 17:21:37 +0300 Subject: [PATCH 45/74] test(09-03): add advanced TypeScript ProtoJSON red test - Cover scalar, optional, map, oneof, recursive, WKT, Any, and explicit-null runtime parity - Execute generated TypeScript through local sdk-spike dependencies and descriptor-backed Protobuf-ES fixtures --- internal/codegen/typescript_runtime_test.go | 435 ++++++++++++++++++++ 1 file changed, 435 insertions(+) diff --git a/internal/codegen/typescript_runtime_test.go b/internal/codegen/typescript_runtime_test.go index 497f201..114b28f 100644 --- a/internal/codegen/typescript_runtime_test.go +++ b/internal/codegen/typescript_runtime_test.go @@ -79,6 +79,195 @@ func TestTypeScriptRuntimeContract(t *testing.T) { } } +func TestTypeScriptRuntimeContract_AdvancedProtoJSON(t *testing.T) { + spikeDir := requireTypeScriptSDKSpike(t) + const protoPath = "runtime/v1/advanced-contract.proto" + plugin := newTempProtogenPlugin(t, map[string]string{ + protoPath: strings.Join([]string{ + `syntax = "proto3";`, + `package runtime.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/runtime;runtimev1";`, + `import "google/protobuf/any.proto";`, + `import "google/protobuf/duration.proto";`, + `import "google/protobuf/field_mask.proto";`, + `import "google/protobuf/struct.proto";`, + `import "google/protobuf/timestamp.proto";`, + `import "google/protobuf/wrappers.proto";`, + `service RuntimeAPI {`, + ` rpc Scalar(ScalarRequest) returns (ScalarResponse);`, + ` rpc Advanced(AdvancedRequest) returns (AdvancedResponse);`, + `}`, + `message ScalarRequest {`, + ` bool bool_flag = 1;`, + ` string text_value = 2;`, + ` bytes bytes_value = 3;`, + ` int32 int32_value = 4;`, + ` sint32 sint32_value = 5;`, + ` sfixed32 sfixed32_value = 6;`, + ` uint32 uint32_value = 7;`, + ` fixed32 fixed32_value = 8;`, + ` int64 int64_value = 9;`, + ` sint64 sint64_value = 10;`, + ` sfixed64 sfixed64_value = 11;`, + ` uint64 uint64_value = 12;`, + ` fixed64 fixed64_value = 13;`, + ` float float_value = 14;`, + ` double double_value = 15;`, + ` Status status = 16;`, + ` Detail details = 17;`, + ` repeated int32 samples = 18;`, + ` optional bool optional_bool_flag = 19;`, + ` optional string optional_text_value = 20;`, + ` optional bytes optional_bytes_value = 21;`, + ` optional int32 optional_int32_value = 22;`, + ` optional uint64 optional_uint64_value = 23;`, + ` optional float optional_float_value = 24;`, + ` optional double optional_double_value = 25;`, + ` optional Status optional_status = 26;`, + `}`, + `message ScalarResponse {`, + ` bool bool_flag = 1;`, + ` string text_value = 2;`, + ` bytes bytes_value = 3;`, + ` int32 int32_value = 4;`, + ` sint32 sint32_value = 5;`, + ` sfixed32 sfixed32_value = 6;`, + ` uint32 uint32_value = 7;`, + ` fixed32 fixed32_value = 8;`, + ` int64 int64_value = 9;`, + ` sint64 sint64_value = 10;`, + ` sfixed64 sfixed64_value = 11;`, + ` uint64 uint64_value = 12;`, + ` fixed64 fixed64_value = 13;`, + ` float float_value = 14;`, + ` double double_value = 15;`, + ` Status status = 16;`, + ` Detail details = 17;`, + ` repeated int32 samples = 18;`, + ` optional bool optional_bool_flag = 19;`, + ` optional string optional_text_value = 20;`, + ` optional bytes optional_bytes_value = 21;`, + ` optional int32 optional_int32_value = 22;`, + ` optional uint64 optional_uint64_value = 23;`, + ` optional float optional_float_value = 24;`, + ` optional double optional_double_value = 25;`, + ` optional Status optional_status = 26;`, + `}`, + `message AdvancedRequest {`, + ` map labels = 1;`, + ` map quantities = 2;`, + ` map toggles = 3;`, + ` map limits = 4;`, + ` optional google.protobuf.Timestamp observed_at = 5;`, + ` optional google.protobuf.Duration ttl = 6;`, + ` optional google.protobuf.Struct payload = 7;`, + ` optional google.protobuf.ListValue items = 8;`, + ` optional google.protobuf.Value dynamic = 9;`, + ` optional google.protobuf.StringValue note = 10;`, + ` optional google.protobuf.Int64Value total = 11;`, + ` optional google.protobuf.BoolValue enabled = 12;`, + ` optional google.protobuf.DoubleValue ratio = 13;`, + ` optional google.protobuf.FieldMask mask = 14;`, + ` optional google.protobuf.BytesValue blob = 15;`, + ` optional google.protobuf.Int32Value small_total = 16;`, + ` optional google.protobuf.UInt32Value uint_total = 17;`, + ` optional google.protobuf.UInt64Value huge_total = 18;`, + ` optional google.protobuf.FloatValue weight = 19;`, + ` optional double raw_ratio = 20;`, + ` optional RecursiveNode tree = 21;`, + ` optional google.protobuf.Any detail_any = 22;`, + ` optional google.protobuf.Any duration_any = 23;`, + ` oneof selector {`, + ` string city_alias = 24;`, + ` int64 city_id = 25;`, + ` Detail city_details = 26;`, + ` }`, + `}`, + `message AdvancedResponse {`, + ` map labels = 1;`, + ` map quantities = 2;`, + ` map toggles = 3;`, + ` map limits = 4;`, + ` optional google.protobuf.Timestamp observed_at = 5;`, + ` optional google.protobuf.Duration ttl = 6;`, + ` optional google.protobuf.Struct payload = 7;`, + ` optional google.protobuf.ListValue items = 8;`, + ` optional google.protobuf.Value dynamic = 9;`, + ` optional google.protobuf.StringValue note = 10;`, + ` optional google.protobuf.Int64Value total = 11;`, + ` optional google.protobuf.BoolValue enabled = 12;`, + ` optional google.protobuf.DoubleValue ratio = 13;`, + ` optional google.protobuf.FieldMask mask = 14;`, + ` optional google.protobuf.BytesValue blob = 15;`, + ` optional google.protobuf.Int32Value small_total = 16;`, + ` optional google.protobuf.UInt32Value uint_total = 17;`, + ` optional google.protobuf.UInt64Value huge_total = 18;`, + ` optional google.protobuf.FloatValue weight = 19;`, + ` optional double raw_ratio = 20;`, + ` optional RecursiveNode tree = 21;`, + ` optional google.protobuf.Any detail_any = 22;`, + ` optional google.protobuf.Any duration_any = 23;`, + ` oneof selector {`, + ` string city_alias = 24;`, + ` int64 city_id = 25;`, + ` Detail city_details = 26;`, + ` }`, + `}`, + `message Detail { string label = 1; }`, + `message RecursiveNode {`, + ` string name = 1;`, + ` optional RecursiveNode child = 2;`, + ` repeated RecursiveNode children = 3;`, + `}`, + `enum Status {`, + ` STATUS_NONE = 0;`, + ` STATUS_OK = 1;`, + ` STATUS_FAILED = 2;`, + `}`, + ``, + }, "\n"), + }, protoPath) + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate TypeScript: %v", err) + } + + tempProject, err := os.MkdirTemp(spikeDir, ".tmp-typescript-advanced-runtime-*") + if err != nil { + t.Fatalf("create temporary TypeScript advanced runtime project under sdk-spike: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tempProject); err != nil { + t.Errorf("remove temporary TypeScript advanced runtime project: %v", err) + } + }) + + sourceDir := filepath.Join(tempProject, "runtime", "v1") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("create advanced runtime fixture source dir: %v", err) + } + + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "advanced_contract_mcp.ts"), generatedFileContent(t, plugin, "runtime/v1/advanced_contract_mcp.ts")) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "advanced_contract_pb.ts"), []byte(protobufESAdvancedRuntimeFixtureModule(t, plugin, protoPath))) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "runtime.ts"), []byte(typeScriptAdvancedRuntimeContractFixture())) + writeTypeScriptFixtureFile(t, filepath.Join(tempProject, "tsconfig.json"), []byte(typeScriptAdvancedRuntimeFixtureTSConfig())) + + compile := exec.Command("npm", "exec", "--prefix", spikeDir, "--", "tsc", "-p", filepath.Join(tempProject, "tsconfig.json")) + compile.Dir = spikeDir + compile.Env = append(os.Environ(), "NO_COLOR=1") + compileOutput, err := compile.CombinedOutput() + if err != nil { + t.Fatalf("generated TypeScript advanced runtime contract failed NodeNext compile via npm exec:\n%s", string(compileOutput)) + } + + run := exec.Command("node", filepath.Join(tempProject, "dist", "runtime", "v1", "runtime.js")) + run.Dir = spikeDir + run.Env = append(os.Environ(), "NO_COLOR=1") + runOutput, err := run.CombinedOutput() + if err != nil { + t.Fatalf("generated TypeScript advanced runtime contract failed under node:\n%s", string(runOutput)) + } +} + func protobufESRuntimeFixtureModule(t *testing.T, plugin *protogen.Plugin, protoPath string) string { t.Helper() @@ -106,6 +295,43 @@ export const EchoResponseSchema: GenMessage = messageDesc(file_run `, descriptorBase64) } +func protobufESAdvancedRuntimeFixtureModule(t *testing.T, plugin *protogen.Plugin, protoPath string) string { + t.Helper() + + descriptorBase64 := protobufESFileDescriptorBase64(t, plugin, protoPath) + return fmt.Sprintf(` +import type { Message } from "@bufbuild/protobuf"; +import { fileDesc, messageDesc, type GenFile, type GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { + file_google_protobuf_any, + file_google_protobuf_duration, + file_google_protobuf_field_mask, + file_google_protobuf_struct, + file_google_protobuf_timestamp, + file_google_protobuf_wrappers, +} from "@bufbuild/protobuf/wkt"; + +export const file_runtime_v1_advanced_contract: GenFile = fileDesc(%q, [ + file_google_protobuf_any, + file_google_protobuf_duration, + file_google_protobuf_field_mask, + file_google_protobuf_struct, + file_google_protobuf_timestamp, + file_google_protobuf_wrappers, +]); + +export type ScalarRequest = Message<"runtime.v1.ScalarRequest"> & Record; +export type ScalarResponse = Message<"runtime.v1.ScalarResponse"> & Record; +export type AdvancedRequest = Message<"runtime.v1.AdvancedRequest"> & Record; +export type AdvancedResponse = Message<"runtime.v1.AdvancedResponse"> & Record; + +export const ScalarRequestSchema: GenMessage = messageDesc(file_runtime_v1_advanced_contract, 0); +export const ScalarResponseSchema: GenMessage = messageDesc(file_runtime_v1_advanced_contract, 1); +export const AdvancedRequestSchema: GenMessage = messageDesc(file_runtime_v1_advanced_contract, 2); +export const AdvancedResponseSchema: GenMessage = messageDesc(file_runtime_v1_advanced_contract, 3); +`, descriptorBase64) +} + func protobufESFileDescriptorBase64(t *testing.T, plugin *protogen.Plugin, protoPath string) string { t.Helper() @@ -262,6 +488,193 @@ async function expectRuntimeBug(run: () => Promise, message: RegExp): P ` } +func typeScriptAdvancedRuntimeContractFixture() string { + return ` +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { registerRuntimeAPITools, type RuntimeAPIToolHandler } from "./advanced_contract_mcp.js"; +import type { AdvancedRequest, AdvancedResponse, ScalarRequest, ScalarResponse } from "./advanced_contract_pb.js"; + +type TestToolResult = { + content: { type: "text"; text: string }[]; + structuredContent?: Record; + isError?: boolean; +}; + +const handler: RuntimeAPIToolHandler = { + scalar(_ctx, request: ScalarRequest): ScalarResponse { + return { + ...(request as Record), + $typeName: "runtime.v1.ScalarResponse", + } as ScalarResponse; + }, + advanced(_ctx, request: AdvancedRequest): AdvancedResponse { + return { + ...(request as Record), + $typeName: "runtime.v1.AdvancedResponse", + } as AdvancedResponse; + }, +}; + +const server = new Server( + { name: "generated-typescript-advanced-runtime", version: "0.0.0" }, + { capabilities: { tools: {} } }, +); +registerRuntimeAPITools(server, handler, ""); + +const client = new Client( + { name: "generated-typescript-advanced-runtime-client", version: "0.0.0" }, + { capabilities: {} }, +); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +const listed = await client.listTools(); +assert.deepEqual(listed.tools.map((tool) => tool.name).sort(), ["Advanced", "Scalar"]); +for (const tool of listed.tools) { + assert.equal(tool.inputSchema.type, "object"); + assert.equal(tool.outputSchema?.type, "object"); +} + +const scalarArgs = { + boolFlag: true, + textValue: "scalar-demo", + bytesValue: "aGVsbG8=", + int32Value: -123, + sint32Value: -321, + sfixed32Value: -456, + uint32Value: 123, + fixed32Value: 456, + int64Value: "-4567890123", + sint64Value: "-77", + sfixed64Value: "-88", + uint64Value: "4567890123", + fixed64Value: "42", + floatValue: 1.25, + doubleValue: 2.5, + status: "STATUS_OK", + details: { label: "plain" }, + samples: [1, 2, 3], + optionalBoolFlag: true, + optionalTextValue: "optional-demo", + optionalBytesValue: "d29ybGQ=", + optionalInt32Value: 7, + optionalUint64Value: "15", + optionalFloatValue: 3.5, + optionalDoubleValue: 4.5, + optionalStatus: "STATUS_FAILED", +}; +const scalar = await client.callTool({ name: "Scalar", arguments: scalarArgs }) as TestToolResult; +assertSuccessfulTextMatchesStructured("Scalar", scalar); +assert.deepEqual(scalar.structuredContent, scalarArgs); + +const nullableScalar = await client.callTool({ + name: "Scalar", + arguments: { + ...scalarArgs, + optionalBoolFlag: null, + optionalTextValue: null, + optionalBytesValue: null, + optionalInt32Value: null, + optionalUint64Value: null, + optionalFloatValue: null, + optionalDoubleValue: null, + optionalStatus: null, + }, +}) as TestToolResult; +assertSuccessfulTextMatchesStructured("Scalar nullable", nullableScalar); +assert.equal(nullableScalar.structuredContent?.optionalTextValue, undefined); +assert.equal(nullableScalar.structuredContent?.optionalStatus, undefined); + +const advancedArgs = { + labels: { env: "prod", team: "core" }, + quantities: { "1": "one", "2": "two" }, + toggles: { true: "enabled", false: "disabled" }, + limits: { "18446744073709551615": "max" }, + observedAt: "2026-03-09T10:11:12Z", + ttl: "3600s", + payload: { kind: "demo", nested: { ok: true } }, + items: ["a", 2, false, { x: "y" }], + dynamic: { city: "Paris", score: 7 }, + note: "hello", + total: "42", + enabled: true, + ratio: "NaN", + mask: "labels,observedAt", + blob: "aGVsbG8=", + smallTotal: 7, + uintTotal: 11, + hugeTotal: "99", + weight: 1.5, + rawRatio: "-Infinity", + tree: { + name: "root", + child: { name: "leaf" }, + children: [{ name: "branch" }], + }, + detailAny: { + "@type": "type.googleapis.com/runtime.v1.Detail", + label: "from-any", + }, + durationAny: { + "@type": "type.googleapis.com/google.protobuf.Duration", + value: "3600s", + }, + cityId: "9876543210", +}; +const advanced = await client.callTool({ name: "Advanced", arguments: advancedArgs }) as TestToolResult; +assertSuccessfulTextMatchesStructured("Advanced", advanced); +assert.deepEqual(advanced.structuredContent, advancedArgs); + +const nullableAdvanced = await client.callTool({ + name: "Advanced", + arguments: { + labels: null, + quantities: null, + toggles: null, + limits: null, + observedAt: null, + ttl: null, + payload: null, + items: null, + dynamic: null, + note: null, + total: null, + enabled: null, + ratio: null, + mask: null, + blob: null, + smallTotal: null, + uintTotal: null, + hugeTotal: null, + weight: null, + rawRatio: null, + tree: null, + detailAny: null, + durationAny: null, + cityAlias: null, + }, +}) as TestToolResult; +assertSuccessfulTextMatchesStructured("Advanced nullable", nullableAdvanced); +assert.deepEqual(nullableAdvanced.structuredContent?.labels, {}); +assert.equal(nullableAdvanced.structuredContent?.observedAt, undefined); +assert.equal(nullableAdvanced.structuredContent?.detailAny, undefined); +assert.equal(nullableAdvanced.structuredContent?.cityAlias, undefined); + +await client.close(); +await server.close(); + +function assertSuccessfulTextMatchesStructured(toolName: string, result: TestToolResult): void { + assert.equal(result.isError, undefined, toolName + " returned tool error"); + assert.equal(result.content.length, 1, toolName + " content item count"); + assert.deepEqual(JSON.parse(result.content[0]?.text ?? ""), result.structuredContent); +} +` +} + func typeScriptRuntimeFixtureTSConfig() string { return ` { @@ -283,3 +696,25 @@ func typeScriptRuntimeFixtureTSConfig() string { } ` } + +func typeScriptAdvancedRuntimeFixtureTSConfig() string { + return ` +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "rootDir": ".", + "outDir": "dist" + }, + "include": [ + "runtime/v1/advanced_contract_mcp.ts", + "runtime/v1/advanced_contract_pb.ts", + "runtime/v1/runtime.ts" + ] +} +` +} From 53681368f7f8d047b6e216c22269a6471f30a9b3 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 17:23:19 +0300 Subject: [PATCH 46/74] feat(09-03): complete TypeScript advanced ProtoJSON parity - Build Protobuf-ES registries with dependency descriptors for Any WKT payloads - Require successful structuredContent to be a JSON object before output validation - Align advanced runtime assertions with canonical ProtoJSON output --- internal/codegen/render_typescript.go | 32 ++++++++++++++++++--- internal/codegen/typescript_runtime_test.go | 9 +++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/internal/codegen/render_typescript.go b/internal/codegen/render_typescript.go index 1a95caf..59dfb7e 100644 --- a/internal/codegen/render_typescript.go +++ b/internal/codegen/render_typescript.go @@ -411,13 +411,21 @@ func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { generated.P(" } catch (error) {") generated.P(" throw new Error(`mcpruntime: marshal output for tool '${tool.name}': ${errorMessage(error)}`);") generated.P(" }") - generated.P(" assertValid(tool.name, \"output\", tool.validateOutput, payload);") + generated.P(" const structuredContent = assertStructuredContent(tool.name, payload);") + generated.P(" assertValid(tool.name, \"output\", tool.validateOutput, structuredContent);") generated.P(" return {") - generated.P(` content: [{ type: "text", text: JSON.stringify(payload) }],`) - generated.P(" structuredContent: payload as Record,") + generated.P(` content: [{ type: "text", text: JSON.stringify(structuredContent) }],`) + generated.P(" structuredContent,") generated.P(" };") generated.P("}") generated.P() + generated.P("function assertStructuredContent(toolName: string, payload: JsonValue): Record {") + generated.P(" if (payload === null || typeof payload !== \"object\" || Array.isArray(payload)) {") + generated.P(" throw new Error(`mcpruntime: marshal output for tool '${toolName}': generated output must be a JSON object`);") + generated.P(" }") + generated.P(" return payload as Record;") + generated.P("}") + generated.P() generated.P("function errorMessage(error: unknown): string {") generated.P(` if (error instanceof Error && error.message !== "") {`) generated.P(" return error.message;") @@ -426,7 +434,23 @@ func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { generated.P("}") generated.P() generated.P("function buildProtoRegistry(...fileRegistries: DescFile[]): Registry {") - generated.P(" return createRegistry(...fileRegistries);") + generated.P(" const collected: DescFile[] = [];") + generated.P(" const seen = new Set();") + generated.P(" const visit = (file: DescFile): void => {") + generated.P(" const key = file.proto.name || file.name;") + generated.P(" if (seen.has(key)) {") + generated.P(" return;") + generated.P(" }") + generated.P(" seen.add(key);") + generated.P(" collected.push(file);") + generated.P(" for (const dependency of file.dependencies) {") + generated.P(" visit(dependency);") + generated.P(" }") + generated.P(" };") + generated.P(" for (const file of fileRegistries) {") + generated.P(" visit(file);") + generated.P(" }") + generated.P(" return createRegistry(...collected);") generated.P("}") generated.P() generated.P("function resolveToolName(") diff --git a/internal/codegen/typescript_runtime_test.go b/internal/codegen/typescript_runtime_test.go index 114b28f..618a5cd 100644 --- a/internal/codegen/typescript_runtime_test.go +++ b/internal/codegen/typescript_runtime_test.go @@ -627,7 +627,14 @@ const advancedArgs = { }; const advanced = await client.callTool({ name: "Advanced", arguments: advancedArgs }) as TestToolResult; assertSuccessfulTextMatchesStructured("Advanced", advanced); -assert.deepEqual(advanced.structuredContent, advancedArgs); +assert.deepEqual(advanced.structuredContent, { + ...advancedArgs, + tree: { + name: "root", + child: { name: "leaf", children: [] }, + children: [{ name: "branch", children: [] }], + }, +}); const nullableAdvanced = await client.callTool({ name: "Advanced", From 62d4f91db44752fae138e099f1919a5fa1c47496 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 17:25:47 +0300 Subject: [PATCH 47/74] test(09-03): refresh TypeScript advanced runtime contracts - Lock structuredContent object validation snippets - Refresh TypeScript golden output for dependency-aware Protobuf-ES registries - Keep high-level SDK and Zod omissions enforced --- internal/codegen/generator_test.go | 5 ++- internal/codegen/typescript_contract_test.go | 11 +++++-- testdata/golden/example_mcp.ts.golden | 32 +++++++++++++++++--- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 067ae8d..305daa3 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -175,7 +175,10 @@ func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { "fromJson(tool.inputSchema, args, { registry: tool.registry })", "toJson(tool.outputSchema, response as MessageShape, {", "alwaysEmitImplicit: true", - "structuredContent: payload as Record", + "const structuredContent = assertStructuredContent(tool.name, payload);", + "function assertStructuredContent(toolName: string, payload: JsonValue): Record {", + "generated output must be a JSON object", + "return createRegistry(...collected);", "new McpError(ErrorCode.InvalidParams", "mcpruntime: validate output for tool", } diff --git a/internal/codegen/typescript_contract_test.go b/internal/codegen/typescript_contract_test.go index fae706f..ed9b7a8 100644 --- a/internal/codegen/typescript_contract_test.go +++ b/internal/codegen/typescript_contract_test.go @@ -115,8 +115,15 @@ func TestTypeScriptContract_RuntimeDispatchPaths(t *testing.T) { "fromJson(tool.inputSchema, args, { registry: tool.registry })", "toJson(tool.outputSchema, response as MessageShape, {", "alwaysEmitImplicit: true", - "assertValid(tool.name, \"output\", tool.validateOutput, payload);", - "structuredContent: payload as Record", + "const structuredContent = assertStructuredContent(tool.name, payload);", + "assertValid(tool.name, \"output\", tool.validateOutput, structuredContent);", + "function assertStructuredContent(toolName: string, payload: JsonValue): Record {", + "generated output must be a JSON object", + "content: [{ type: \"text\", text: JSON.stringify(structuredContent) }]", + "structuredContent,", + "const collected: DescFile[] = [];", + "for (const dependency of file.dependencies) {", + "return createRegistry(...collected);", "mcpruntime: validate output for tool", "mcpruntime: marshal output for tool", } diff --git a/testdata/golden/example_mcp.ts.golden b/testdata/golden/example_mcp.ts.golden index 2ca56c9..5c4cea4 100644 --- a/testdata/golden/example_mcp.ts.golden +++ b/testdata/golden/example_mcp.ts.golden @@ -352,13 +352,21 @@ async function dispatchToolCall( } catch (error) { throw new Error(`mcpruntime: marshal output for tool '${tool.name}': ${errorMessage(error)}`); } - assertValid(tool.name, "output", tool.validateOutput, payload); + const structuredContent = assertStructuredContent(tool.name, payload); + assertValid(tool.name, "output", tool.validateOutput, structuredContent); return { - content: [{ type: "text", text: JSON.stringify(payload) }], - structuredContent: payload as Record, + content: [{ type: "text", text: JSON.stringify(structuredContent) }], + structuredContent, }; } +function assertStructuredContent(toolName: string, payload: JsonValue): Record { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error(`mcpruntime: marshal output for tool '${toolName}': generated output must be a JSON object`); + } + return payload as Record; +} + function errorMessage(error: unknown): string { if (error instanceof Error && error.message !== "") { return error.message; @@ -367,7 +375,23 @@ function errorMessage(error: unknown): string { } function buildProtoRegistry(...fileRegistries: DescFile[]): Registry { - return createRegistry(...fileRegistries); + const collected: DescFile[] = []; + const seen = new Set(); + const visit = (file: DescFile): void => { + const key = file.proto.name || file.name; + if (seen.has(key)) { + return; + } + seen.add(key); + collected.push(file); + for (const dependency of file.dependencies) { + visit(dependency); + } + }; + for (const file of fileRegistries) { + visit(file); + } + return createRegistry(...collected); } function resolveToolName( From f73f28050d824160ffb897ffc031aa7eb719ce4c Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Wed, 6 May 2026 21:05:37 +0300 Subject: [PATCH 48/74] fix(09): share typescript registry across sidecars --- internal/codegen/generator_test.go | 5 +- internal/codegen/render_typescript.go | 11 +- internal/codegen/typescript_contract_test.go | 4 + internal/codegen/typescript_runtime_test.go | 190 +++++++++++++++++++ testdata/golden/example_mcp.ts.golden | 11 +- 5 files changed, 214 insertions(+), 7 deletions(-) diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 305daa3..03482f5 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -164,7 +164,10 @@ func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { `import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`, `import { Ajv2020 } from "ajv/dist/2020.js";`, `import type { ValidateFunction } from "ajv/dist/2020.js";`, + `const toolRegistrySymbol: unique symbol = Symbol.for("protoc-gen-mcp.typescript.toolRegistry") as never;`, "const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true });", + "type ServerWithGeneratedToolRegistry = Server & {", + "[toolRegistrySymbol]?: ServerToolRegistry;", "type ServerToolRegistry = {", "function installMcpHandlers(server: Server, registry: ServerToolRegistry): void {", "function listRegisteredTools(registry: ServerToolRegistry): Tool[] {", @@ -187,7 +190,7 @@ func TestGenerate_TypeScriptTargetEmitsOutput(t *testing.T) { t.Fatalf("generated TypeScript missing snippet %q\n%s", snippet, generated) } } - for _, snippet := range []string{"registerTool", "addTool", "zod", "toJsonSchemaCompat", "lang=javascript"} { + for _, snippet := range []string{"new WeakMap()", "registerTool", "addTool", "zod", "toJsonSchemaCompat", "lang=javascript"} { if strings.Contains(generated, snippet) { t.Fatalf("generated TypeScript foundation must not contain snippet %q\n%s", snippet, generated) } diff --git a/internal/codegen/render_typescript.go b/internal/codegen/render_typescript.go index 59dfb7e..a6be772 100644 --- a/internal/codegen/render_typescript.go +++ b/internal/codegen/render_typescript.go @@ -274,14 +274,19 @@ func renderTypeScriptExecution(generated *protogen.GeneratedFile, taskSupport mc } func renderTypeScriptPrivateRegistryHelpers(generated *protogen.GeneratedFile) { - generated.P("const toolRegistries = new WeakMap();") + generated.P(`const toolRegistrySymbol: unique symbol = Symbol.for("protoc-gen-mcp.typescript.toolRegistry") as never;`) generated.P("const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true });") generated.P() + generated.P("type ServerWithGeneratedToolRegistry = Server & {") + generated.P(" [toolRegistrySymbol]?: ServerToolRegistry;") + generated.P("};") + generated.P() generated.P("function getToolRegistry(server: Server): ServerToolRegistry {") - generated.P(" let registry = toolRegistries.get(server);") + generated.P(" const holder = server as ServerWithGeneratedToolRegistry;") + generated.P(" let registry = holder[toolRegistrySymbol];") generated.P(" if (registry === undefined) {") generated.P(" registry = { tools: [], handlersInstalled: false };") - generated.P(" toolRegistries.set(server, registry);") + generated.P(" holder[toolRegistrySymbol] = registry;") generated.P(" }") generated.P(" return registry;") generated.P("}") diff --git a/internal/codegen/typescript_contract_test.go b/internal/codegen/typescript_contract_test.go index ed9b7a8..fa34d49 100644 --- a/internal/codegen/typescript_contract_test.go +++ b/internal/codegen/typescript_contract_test.go @@ -96,7 +96,10 @@ func TestTypeScriptContract_RuntimeDispatchPaths(t *testing.T) { `import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js";`, `import { Ajv2020 } from "ajv/dist/2020.js";`, `import type { ValidateFunction } from "ajv/dist/2020.js";`, + `const toolRegistrySymbol: unique symbol = Symbol.for("protoc-gen-mcp.typescript.toolRegistry") as never;`, "const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true });", + "type ServerWithGeneratedToolRegistry = Server & {", + "[toolRegistrySymbol]?: ServerToolRegistry;", "type ServerToolRegistry = {", "function installMcpHandlers(server: Server, registry: ServerToolRegistry): void {", "function listRegisteredTools(registry: ServerToolRegistry): Tool[] {", @@ -130,6 +133,7 @@ func TestTypeScriptContract_RuntimeDispatchPaths(t *testing.T) { assertTypeScriptContains(t, generated, wantSnippets...) notWantSnippets := []string{ + "new WeakMap()", "registerTool", "addTool", "zod", diff --git a/internal/codegen/typescript_runtime_test.go b/internal/codegen/typescript_runtime_test.go index 618a5cd..a67d28d 100644 --- a/internal/codegen/typescript_runtime_test.go +++ b/internal/codegen/typescript_runtime_test.go @@ -79,6 +79,77 @@ func TestTypeScriptRuntimeContract(t *testing.T) { } } +func TestTypeScriptRuntimeContract_MultipleSidecarsShareServerRegistry(t *testing.T) { + spikeDir := requireTypeScriptSDKSpike(t) + const firstProtoPath = "runtime/v1/first-contract.proto" + const secondProtoPath = "runtime/v1/second-contract.proto" + plugin := newTempProtogenPlugin(t, map[string]string{ + firstProtoPath: strings.Join([]string{ + `syntax = "proto3";`, + `package runtime.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/runtime;runtimev1";`, + `service FirstAPI {`, + ` rpc Ping(FirstRequest) returns (FirstResponse);`, + `}`, + `message FirstRequest { string label = 1; }`, + `message FirstResponse { string label = 1; }`, + ``, + }, "\n"), + secondProtoPath: strings.Join([]string{ + `syntax = "proto3";`, + `package runtime.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/runtime;runtimev1";`, + `service SecondAPI {`, + ` rpc Pong(SecondRequest) returns (SecondResponse);`, + `}`, + `message SecondRequest { string label = 1; }`, + `message SecondResponse { string label = 1; }`, + ``, + }, "\n"), + }, firstProtoPath, secondProtoPath) + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate TypeScript: %v", err) + } + + tempProject, err := os.MkdirTemp(spikeDir, ".tmp-typescript-multi-sidecar-runtime-*") + if err != nil { + t.Fatalf("create temporary TypeScript multi-sidecar runtime project under sdk-spike: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tempProject); err != nil { + t.Errorf("remove temporary TypeScript multi-sidecar runtime project: %v", err) + } + }) + + sourceDir := filepath.Join(tempProject, "runtime", "v1") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("create multi-sidecar runtime fixture source dir: %v", err) + } + + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "first_contract_mcp.ts"), generatedFileContent(t, plugin, "runtime/v1/first_contract_mcp.ts")) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "first_contract_pb.ts"), []byte(protobufESMultiSidecarRuntimeFixtureModule(t, plugin, firstProtoPath, "file_runtime_v1_first_contract", "FirstRequest", "FirstResponse"))) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "second_contract_mcp.ts"), generatedFileContent(t, plugin, "runtime/v1/second_contract_mcp.ts")) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "second_contract_pb.ts"), []byte(protobufESMultiSidecarRuntimeFixtureModule(t, plugin, secondProtoPath, "file_runtime_v1_second_contract", "SecondRequest", "SecondResponse"))) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "runtime.ts"), []byte(typeScriptMultiSidecarRuntimeContractFixture())) + writeTypeScriptFixtureFile(t, filepath.Join(tempProject, "tsconfig.json"), []byte(typeScriptMultiSidecarRuntimeFixtureTSConfig())) + + compile := exec.Command("npm", "exec", "--prefix", spikeDir, "--", "tsc", "-p", filepath.Join(tempProject, "tsconfig.json")) + compile.Dir = spikeDir + compile.Env = append(os.Environ(), "NO_COLOR=1") + compileOutput, err := compile.CombinedOutput() + if err != nil { + t.Fatalf("generated TypeScript multi-sidecar runtime contract failed NodeNext compile via npm exec:\n%s", string(compileOutput)) + } + + run := exec.Command("node", filepath.Join(tempProject, "dist", "runtime", "v1", "runtime.js")) + run.Dir = spikeDir + run.Env = append(os.Environ(), "NO_COLOR=1") + runOutput, err := run.CombinedOutput() + if err != nil { + t.Fatalf("generated TypeScript multi-sidecar runtime contract failed under node:\n%s", string(runOutput)) + } +} + func TestTypeScriptRuntimeContract_AdvancedProtoJSON(t *testing.T) { spikeDir := requireTypeScriptSDKSpike(t) const protoPath = "runtime/v1/advanced-contract.proto" @@ -295,6 +366,29 @@ export const EchoResponseSchema: GenMessage = messageDesc(file_run `, descriptorBase64) } +func protobufESMultiSidecarRuntimeFixtureModule(t *testing.T, plugin *protogen.Plugin, protoPath, fileRegistry, requestName, responseName string) string { + t.Helper() + + descriptorBase64 := protobufESFileDescriptorBase64(t, plugin, protoPath) + return fmt.Sprintf(` +import type { Message } from "@bufbuild/protobuf"; +import { fileDesc, messageDesc, type GenFile, type GenMessage } from "@bufbuild/protobuf/codegenv2"; + +export const %[1]s: GenFile = fileDesc(%[2]q); + +export type %[3]s = Message<"runtime.v1.%[3]s"> & { + label: string; +}; + +export type %[4]s = Message<"runtime.v1.%[4]s"> & { + label: string; +}; + +export const %[3]sSchema: GenMessage<%[3]s> = messageDesc(%[1]s, 0); +export const %[4]sSchema: GenMessage<%[4]s> = messageDesc(%[1]s, 1); +`, fileRegistry, descriptorBase64, requestName, responseName) +} + func protobufESAdvancedRuntimeFixtureModule(t *testing.T, plugin *protogen.Plugin, protoPath string) string { t.Helper() @@ -488,6 +582,78 @@ async function expectRuntimeBug(run: () => Promise, message: RegExp): P ` } +func typeScriptMultiSidecarRuntimeContractFixture() string { + return ` +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { registerFirstAPITools, type FirstAPIToolHandler } from "./first_contract_mcp.js"; +import { registerSecondAPITools, type SecondAPIToolHandler } from "./second_contract_mcp.js"; +import type { FirstRequest, FirstResponse } from "./first_contract_pb.js"; +import type { SecondRequest, SecondResponse } from "./second_contract_pb.js"; + +type TestToolResult = { + content: { type: "text"; text: string }[]; + structuredContent?: Record; + isError?: boolean; +}; + +const firstHandler: FirstAPIToolHandler = { + ping(_ctx, request: FirstRequest): FirstResponse { + return { + $typeName: "runtime.v1.FirstResponse", + label: "first:" + request.label, + }; + }, +}; + +const secondHandler: SecondAPIToolHandler = { + pong(_ctx, request: SecondRequest): SecondResponse { + return { + $typeName: "runtime.v1.SecondResponse", + label: "second:" + request.label, + }; + }, +}; + +const server = new Server( + { name: "generated-typescript-multi-sidecar-runtime", version: "0.0.0" }, + { capabilities: { tools: {} } }, +); +registerFirstAPITools(server, firstHandler, ""); +registerSecondAPITools(server, secondHandler, ""); + +const client = new Client( + { name: "generated-typescript-multi-sidecar-runtime-client", version: "0.0.0" }, + { capabilities: {} }, +); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +const listed = await client.listTools(); +assert.deepEqual(listed.tools.map((tool) => tool.name).sort(), ["Ping", "Pong"]); + +const first = await client.callTool({ + name: "Ping", + arguments: { label: "alpha" }, +}) as TestToolResult; +assert.deepEqual(first.structuredContent, { label: "first:alpha" }); +assert.deepEqual(JSON.parse(first.content[0]?.text ?? ""), first.structuredContent); + +const second = await client.callTool({ + name: "Pong", + arguments: { label: "beta" }, +}) as TestToolResult; +assert.deepEqual(second.structuredContent, { label: "second:beta" }); +assert.deepEqual(JSON.parse(second.content[0]?.text ?? ""), second.structuredContent); + +await client.close(); +await server.close(); +` +} + func typeScriptAdvancedRuntimeContractFixture() string { return ` import assert from "node:assert/strict"; @@ -704,6 +870,30 @@ func typeScriptRuntimeFixtureTSConfig() string { ` } +func typeScriptMultiSidecarRuntimeFixtureTSConfig() string { + return ` +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "rootDir": ".", + "outDir": "dist" + }, + "include": [ + "runtime/v1/first_contract_mcp.ts", + "runtime/v1/first_contract_pb.ts", + "runtime/v1/second_contract_mcp.ts", + "runtime/v1/second_contract_pb.ts", + "runtime/v1/runtime.ts" + ] +} +` +} + func typeScriptAdvancedRuntimeFixtureTSConfig() string { return ` { diff --git a/testdata/golden/example_mcp.ts.golden b/testdata/golden/example_mcp.ts.golden index 5c4cea4..99d154f 100644 --- a/testdata/golden/example_mcp.ts.golden +++ b/testdata/golden/example_mcp.ts.golden @@ -215,14 +215,19 @@ export function registerExampleAPITools( ); } -const toolRegistries = new WeakMap(); +const toolRegistrySymbol: unique symbol = Symbol.for("protoc-gen-mcp.typescript.toolRegistry") as never; const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true }); +type ServerWithGeneratedToolRegistry = Server & { + [toolRegistrySymbol]?: ServerToolRegistry; +}; + function getToolRegistry(server: Server): ServerToolRegistry { - let registry = toolRegistries.get(server); + const holder = server as ServerWithGeneratedToolRegistry; + let registry = holder[toolRegistrySymbol]; if (registry === undefined) { registry = { tools: [], handlersInstalled: false }; - toolRegistries.set(server, registry); + holder[toolRegistrySymbol] = registry; } return registry; } From d565729a2213827c81b3c4b918cb5856d8fdb1b5 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 19:30:29 +0300 Subject: [PATCH 49/74] test(10-03): add generated Node stdio verification --- internal/codegen/typescript_stdio_test.go | 463 ++++++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 internal/codegen/typescript_stdio_test.go diff --git a/internal/codegen/typescript_stdio_test.go b/internal/codegen/typescript_stdio_test.go new file mode 100644 index 0000000..4114798 --- /dev/null +++ b/internal/codegen/typescript_stdio_test.go @@ -0,0 +1,463 @@ +package codegen + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/protobuf/compiler/protogen" +) + +func TestTypeScriptGeneratedNodeServerOverStdio(t *testing.T) { + session := connectTypeScriptGeneratedNodeServer(t, "") + defer session.Close() + + ctx := context.Background() + tools, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools() over generated Node stdio failed: %v", err) + } + + var toolNames []string + for _, tool := range tools.Tools { + toolNames = append(toolNames, tool.Name) + } + slices.Sort(toolNames) + if !slices.Equal(toolNames, []string{"Advanced", "Scalar"}) { + t.Fatalf("tool names = %v, want [Advanced Scalar]", toolNames) + } + + validateTypeScriptToolInputSchema(t, tools.Tools, "Scalar", typeScriptScalarStdioArguments()) + validateTypeScriptToolInputSchema(t, tools.Tools, "Advanced", typeScriptAdvancedStdioArguments()) + + scalarResult, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "Scalar", + Arguments: typeScriptScalarStdioArguments(), + }) + if err != nil { + t.Fatalf("CallTool(Scalar) over generated Node stdio failed: %v", err) + } + if scalarResult.IsError { + t.Fatalf("Scalar returned tool error over generated Node stdio: %+v", scalarResult) + } + assertTypeScriptTextStructuredContentMatch(t, "Scalar", scalarResult) + scalarStructured := decodeTypeScriptStdioMap(t, scalarResult.StructuredContent) + if got := scalarStructured["int64Value"]; got != "-4567890123" { + t.Fatalf("int64Value = %v, want -4567890123", got) + } + if got := scalarStructured["bytesValue"]; got != "aGVsbG8=" { + t.Fatalf("bytesValue = %v, want aGVsbG8=", got) + } + if got := scalarStructured["rawRatio"]; got != "NaN" { + t.Fatalf("rawRatio = %v, want NaN", got) + } + + advancedResult, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "Advanced", + Arguments: typeScriptAdvancedStdioArguments(), + }) + if err != nil { + t.Fatalf("CallTool(Advanced) over generated Node stdio failed: %v", err) + } + if advancedResult.IsError { + t.Fatalf("Advanced returned tool error over generated Node stdio: %+v", advancedResult) + } + assertTypeScriptTextStructuredContentMatch(t, "Advanced", advancedResult) + advancedStructured := decodeTypeScriptStdioMap(t, advancedResult.StructuredContent) + if got := advancedStructured["rawRatio"]; got != "-Infinity" { + t.Fatalf("rawRatio = %v, want -Infinity", got) + } + if got := advancedStructured["blob"]; got != "aGVsbG8=" { + t.Fatalf("blob = %v, want aGVsbG8=", got) + } + if got := advancedStructured["cityAlias"]; got != "paris-fr" { + t.Fatalf("cityAlias = %v, want paris-fr", got) + } + detailAny, ok := advancedStructured["detailAny"].(map[string]any) + if !ok { + t.Fatalf("detailAny has type %T, want map[string]any", advancedStructured["detailAny"]) + } + if got := detailAny["@type"]; got != "type.googleapis.com/runtime.v1.Detail" { + t.Fatalf("detailAny.@type = %v, want runtime detail type URL", got) + } + tree, ok := advancedStructured["tree"].(map[string]any) + if !ok { + t.Fatalf("tree has type %T, want map[string]any", advancedStructured["tree"]) + } + if got := tree["name"]; got != "root" { + t.Fatalf("tree.name = %v, want root", got) + } +} + +func TestTypeScriptGeneratedNodeServerRejectsInvalidInputOverStdio(t *testing.T) { + session := connectTypeScriptGeneratedNodeServer(t, "") + defer session.Close() + + _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "Scalar", + Arguments: map[string]any{ + "textValue": "missing-required-fields", + }, + }) + if err == nil { + t.Fatal("CallTool(Scalar) unexpectedly succeeded with invalid input") + } + lower := strings.ToLower(err.Error()) + if !strings.Contains(lower, "invalid") || !strings.Contains(lower, "scalar") { + t.Fatalf("CallTool(Scalar) error = %v, want invalid-input failure naming Scalar", err) + } +} + +func TestTypeScriptGeneratedNodeServerRejectsInvalidOutputOverStdio(t *testing.T) { + session := connectTypeScriptGeneratedNodeServer(t, "scalar") + defer session.Close() + + _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "Scalar", + Arguments: typeScriptScalarStdioArguments(), + }) + if err == nil { + t.Fatal("CallTool(Scalar) unexpectedly succeeded with invalid output") + } + lower := strings.ToLower(err.Error()) + if !strings.Contains(lower, "validate output") && !strings.Contains(lower, "output") { + t.Fatalf("CallTool(Scalar) error = %v, want output validation failure", err) + } +} + +func connectTypeScriptGeneratedNodeServer(t *testing.T, invalidOutput string) *mcp.ClientSession { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + client := mcp.NewClient(&mcp.Implementation{ + Name: "protoc-gen-mcp-typescript-stdio-test-client", + Version: "v0.0.1", + }, nil) + + session, err := client.Connect(ctx, &mcp.CommandTransport{Command: buildTypeScriptGeneratedNodeServerCommand(t, invalidOutput)}, nil) + if err != nil { + t.Fatalf("client.Connect() to generated Node server over stdio failed: %v", err) + } + return session +} + +func buildTypeScriptGeneratedNodeServerCommand(t *testing.T, invalidOutput string) *exec.Cmd { + t.Helper() + + spikeDir := requireTypeScriptSDKSpike(t) + const protoPath = "runtime/v1/stdio-contract.proto" + plugin := newTempProtogenPlugin(t, map[string]string{ + protoPath: strings.Join([]string{ + `syntax = "proto3";`, + `package runtime.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/runtime;runtimev1";`, + `import "google/protobuf/any.proto";`, + `import "google/protobuf/duration.proto";`, + `import "google/protobuf/timestamp.proto";`, + `import "google/protobuf/wrappers.proto";`, + `service RuntimeAPI {`, + ` rpc Scalar(ScalarRequest) returns (ScalarResponse);`, + ` rpc Advanced(AdvancedRequest) returns (AdvancedResponse);`, + `}`, + `message ScalarRequest {`, + ` string text_value = 1;`, + ` int64 int64_value = 2;`, + ` uint64 uint64_value = 3;`, + ` bytes bytes_value = 4;`, + ` double raw_ratio = 5;`, + `}`, + `message ScalarResponse {`, + ` string text_value = 1;`, + ` int64 int64_value = 2;`, + ` uint64 uint64_value = 3;`, + ` bytes bytes_value = 4;`, + ` double raw_ratio = 5;`, + `}`, + `message AdvancedRequest {`, + ` map labels = 1;`, + ` optional google.protobuf.Timestamp observed_at = 2;`, + ` optional google.protobuf.Duration ttl = 3;`, + ` optional google.protobuf.Any detail_any = 4;`, + ` optional RecursiveNode tree = 5;`, + ` optional double raw_ratio = 6;`, + ` optional google.protobuf.BytesValue blob = 7;`, + ` oneof selector {`, + ` string city_alias = 8;`, + ` int64 city_id = 9;`, + ` Detail city_details = 10;`, + ` }`, + `}`, + `message AdvancedResponse {`, + ` map labels = 1;`, + ` optional google.protobuf.Timestamp observed_at = 2;`, + ` optional google.protobuf.Duration ttl = 3;`, + ` optional google.protobuf.Any detail_any = 4;`, + ` optional RecursiveNode tree = 5;`, + ` optional double raw_ratio = 6;`, + ` optional google.protobuf.BytesValue blob = 7;`, + ` oneof selector {`, + ` string city_alias = 8;`, + ` int64 city_id = 9;`, + ` Detail city_details = 10;`, + ` }`, + `}`, + `message Detail { string label = 1; }`, + `message RecursiveNode {`, + ` string name = 1;`, + ` optional RecursiveNode child = 2;`, + ` repeated RecursiveNode children = 3;`, + `}`, + ``, + }, "\n"), + }, protoPath) + if err := Generate(plugin, Options{Language: LanguageTypeScript}); err != nil { + t.Fatalf("Generate TypeScript stdio fixture: %v", err) + } + + tempProject, err := os.MkdirTemp(spikeDir, ".tmp-typescript-stdio-*") + if err != nil { + t.Fatalf("create temporary TypeScript stdio project under sdk-spike: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tempProject); err != nil { + t.Errorf("remove temporary TypeScript stdio project: %v", err) + } + }) + + sourceDir := filepath.Join(tempProject, "runtime", "v1") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("create TypeScript stdio fixture source dir: %v", err) + } + + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "stdio_contract_mcp.ts"), generatedFileContent(t, plugin, "runtime/v1/stdio_contract_mcp.ts")) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "stdio_contract_pb.ts"), []byte(protobufESStdioFixtureModule(t, plugin, protoPath))) + writeTypeScriptFixtureFile(t, filepath.Join(sourceDir, "stdio_server.ts"), []byte(typeScriptStdioServerFixture())) + writeTypeScriptFixtureFile(t, filepath.Join(tempProject, "tsconfig.json"), []byte(typeScriptStdioFixtureTSConfig())) + + compile := exec.Command("npm", "exec", "--prefix", spikeDir, "--", "tsc", "-p", filepath.Join(tempProject, "tsconfig.json")) + compile.Dir = spikeDir + compile.Env = append(os.Environ(), "NO_COLOR=1") + compileOutput, err := compile.CombinedOutput() + if err != nil { + t.Fatalf("generated TypeScript stdio server failed NodeNext compile via npm exec:\n%s", string(compileOutput)) + } + + cmd := exec.Command("node", filepath.Join(tempProject, "dist", "runtime", "v1", "stdio_server.js")) + cmd.Dir = spikeDir + cmd.Env = append(os.Environ(), "NO_COLOR=1") + if invalidOutput != "" { + cmd.Env = append(cmd.Env, "PROTOC_GEN_MCP_NODE_INVALID_OUTPUT="+invalidOutput) + } + return cmd +} + +func protobufESStdioFixtureModule(t *testing.T, plugin *protogen.Plugin, protoPath string) string { + t.Helper() + + descriptorBase64 := protobufESFileDescriptorBase64(t, plugin, protoPath) + return fmt.Sprintf(` +import type { Message } from "@bufbuild/protobuf"; +import { fileDesc, messageDesc, type GenFile, type GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { + file_google_protobuf_any, + file_google_protobuf_duration, + file_google_protobuf_timestamp, + file_google_protobuf_wrappers, +} from "@bufbuild/protobuf/wkt"; + +export const file_runtime_v1_stdio_contract: GenFile = fileDesc(%q, [ + file_google_protobuf_any, + file_google_protobuf_duration, + file_google_protobuf_timestamp, + file_google_protobuf_wrappers, +]); + +export type ScalarRequest = Message<"runtime.v1.ScalarRequest"> & Record; +export type ScalarResponse = Message<"runtime.v1.ScalarResponse"> & Record; +export type AdvancedRequest = Message<"runtime.v1.AdvancedRequest"> & Record; +export type AdvancedResponse = Message<"runtime.v1.AdvancedResponse"> & Record; + +export const ScalarRequestSchema: GenMessage = messageDesc(file_runtime_v1_stdio_contract, 0); +export const ScalarResponseSchema: GenMessage = messageDesc(file_runtime_v1_stdio_contract, 1); +export const AdvancedRequestSchema: GenMessage = messageDesc(file_runtime_v1_stdio_contract, 2); +export const AdvancedResponseSchema: GenMessage = messageDesc(file_runtime_v1_stdio_contract, 3); +`, descriptorBase64) +} + +func typeScriptStdioServerFixture() string { + return ` +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { registerRuntimeAPITools, type RuntimeAPIToolHandler } from "./stdio_contract_mcp.js"; +import type { AdvancedRequest, AdvancedResponse, ScalarRequest, ScalarResponse } from "./stdio_contract_pb.js"; + +const invalidOutput = process.env.PROTOC_GEN_MCP_NODE_INVALID_OUTPUT ?? ""; + +const handler: RuntimeAPIToolHandler = { + scalar(_ctx, request: ScalarRequest): ScalarResponse { + if (invalidOutput === "scalar") { + return { + ...(request as Record), + $typeName: "runtime.v1.ScalarResponse", + textValue: 123 as unknown as string, + } as ScalarResponse; + } + return { + ...(request as Record), + $typeName: "runtime.v1.ScalarResponse", + } as ScalarResponse; + }, + advanced(_ctx, request: AdvancedRequest): AdvancedResponse { + return { + ...(request as Record), + $typeName: "runtime.v1.AdvancedResponse", + } as AdvancedResponse; + }, +}; + +const server = new Server( + { name: "generated-typescript-stdio", version: "0.0.0" }, + { capabilities: { tools: {} } }, +); +registerRuntimeAPITools(server, handler, ""); +await server.connect(new StdioServerTransport()); +` +} + +func typeScriptStdioFixtureTSConfig() string { + return ` +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "rootDir": ".", + "outDir": "dist" + }, + "include": [ + "runtime/v1/stdio_contract_mcp.ts", + "runtime/v1/stdio_contract_pb.ts", + "runtime/v1/stdio_server.ts" + ] +} +` +} + +func typeScriptScalarStdioArguments() map[string]any { + return map[string]any{ + "textValue": "scalar-demo", + "int64Value": "-4567890123", + "uint64Value": "4567890123", + "bytesValue": "aGVsbG8=", + "rawRatio": "NaN", + } +} + +func typeScriptAdvancedStdioArguments() map[string]any { + return map[string]any{ + "labels": map[string]any{"env": "prod", "team": "core"}, + "observedAt": "2026-03-09T10:11:12Z", + "ttl": "3600s", + "detailAny": map[string]any{ + "@type": "type.googleapis.com/runtime.v1.Detail", + "label": "from-any", + }, + "tree": map[string]any{ + "name": "root", + "child": map[string]any{ + "name": "leaf", + }, + }, + "rawRatio": "-Infinity", + "blob": "aGVsbG8=", + "cityAlias": "paris-fr", + } +} + +func validateTypeScriptToolInputSchema(t *testing.T, tools []*mcp.Tool, toolName string, arguments map[string]any) { + t.Helper() + + tool := findTypeScriptStdioTool(t, tools, toolName) + rawSchema, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatalf("marshal input schema for tool %q: %v", toolName, err) + } + + var schema jsonschema.Schema + if err := json.Unmarshal(rawSchema, &schema); err != nil { + t.Fatalf("unmarshal input schema for tool %q: %v", toolName, err) + } + + resolved, err := schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + if err != nil { + t.Fatalf("resolve input schema for tool %q: %v", toolName, err) + } + if err := resolved.Validate(arguments); err != nil { + t.Fatalf("input schema for tool %q rejected valid arguments %v: %v", toolName, arguments, err) + } +} + +func assertTypeScriptTextStructuredContentMatch(t *testing.T, toolName string, result *mcp.CallToolResult) { + t.Helper() + + if len(result.Content) != 1 { + t.Fatalf("%s returned %d content items, want 1", toolName, len(result.Content)) + } + + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("%s content[0] has type %T, want *mcp.TextContent", toolName, result.Content[0]) + } + + var fromText map[string]any + if err := json.Unmarshal([]byte(textContent.Text), &fromText); err != nil { + t.Fatalf("decode text content for %s: %v", toolName, err) + } + + fromStructured := decodeTypeScriptStdioMap(t, result.StructuredContent) + if !reflect.DeepEqual(fromText, fromStructured) { + t.Fatalf("%s text content %v does not match structured content %v", toolName, fromText, fromStructured) + } +} + +func decodeTypeScriptStdioMap(t *testing.T, value any) map[string]any { + t.Helper() + + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal JSON value: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal JSON value: %v", err) + } + return decoded +} + +func findTypeScriptStdioTool(t *testing.T, tools []*mcp.Tool, toolName string) *mcp.Tool { + t.Helper() + + for _, tool := range tools { + if tool != nil && tool.Name == toolName { + return tool + } + } + t.Fatalf("tool %q not found in tools/list", toolName) + return nil +} From 2e5d0bbcfd26150ebeb697ae078806151c92c67a Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 19:35:12 +0300 Subject: [PATCH 50/74] ci(10-04): add Node verification gate --- .github/workflows/tests.yml | 22 ++++++++++++++++++++++ AGENTS.md | 16 ++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 34e0833..7d7a207 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,6 +31,13 @@ jobs: distribution: "temurin" java-version: "17" + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: examples/node/sdk-spike/package-lock.json + - name: Install Gradle uses: gradle/actions/setup-gradle@v4 with: @@ -66,6 +73,21 @@ jobs: - name: Verify generated files are up to date run: git diff --exit-code + - name: Install Node dependencies + working-directory: examples/node/sdk-spike + run: npm ci + + - name: Typecheck generated TypeScript SDK spike + working-directory: examples/node/sdk-spike + run: npm run typecheck + + - name: Build generated TypeScript SDK spike + working-directory: examples/node/sdk-spike + run: npm run build + + - name: Run generated Node stdio tests + run: go test ./internal/codegen -run 'TestTypeScriptGeneratedNodeServer.*OverStdio|TestTypeScriptGeneratedNodeServerRejectsInvalid(Input|Output)OverStdio' -count=1 + - name: Build JVM examples run: | gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin diff --git a/AGENTS.md b/AGENTS.md index 0a8ffc1..763f9c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,8 @@ architecture unless explicitly revised. low-level official SDK imports, typed public handlers, namespace-aware registration, schema constants, and private registry metadata - `internal/codegen/typescript_*_test.go`: TypeScript semantic model, naming, - renderer contract, golden, and strict NodeNext compile-gate tests + renderer contract, golden, runtime, stdio, and strict NodeNext compile-gate + tests - `internal/examplemcp`: reusable example MCP server wiring and stdio smoke test - `internal/pythontest`: hermetic Python test runtime bootstrap used by Go tests that execute generated Python code @@ -191,6 +192,9 @@ architecture unless explicitly revised. namespace?)`, raw schema JSON constants, imported message schema/file registry refs, metadata registry records, NodeNext `.js` specifiers, and strict `verbatimModuleSyntax` import type/value separation + - generated Node stdio process verification for TypeScript sidecars, covering + `tools/list`, valid tool calls, text/structured output parity, invalid + input rejection, invalid output rejection, and advanced ProtoJSON shapes - shared JVM foundation for `lang=kotlin` and `lang=java`: parser and generator dispatch accept both targets, collect SDK-neutral `internal/codegen/jvm_*.go` models, preserve existing `FileModel` schema @@ -340,8 +344,10 @@ architecture unless explicitly revised. - `go test ./internal/codegen -run 'TestTypeScriptModel|TestTypeScriptNames|TestTypeScriptContract|TestGenerateTypeScript|TestGenerate_TypeScript|TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext' -count=1` for TypeScript semantic model, naming, renderer contract, golden, generator, and strict NodeNext public API compile coverage -- `cd examples/node/sdk-spike && npm ci && npm run typecheck` - for the pinned local TypeScript SDK/Protobuf-ES compile gate +- `cd examples/node/sdk-spike && npm ci && npm run typecheck && npm run build` + for the pinned local TypeScript SDK/Protobuf-ES compile/build gate +- `go test ./internal/codegen -run 'TestTypeScriptGeneratedNodeServer.*OverStdio|TestTypeScriptGeneratedNodeServerRejectsInvalid(Input|Output)OverStdio' -count=1` + for generated Node stdio process verification - `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` for the Phase 04 JVM compile gate - `gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist` @@ -414,12 +420,14 @@ architecture unless explicitly revised. - Generate standalone example artifacts: - `cd examples && make generate` - Run TypeScript Node compile gate: - - `cd examples/node/sdk-spike && npm ci && npm run typecheck` + - `cd examples/node/sdk-spike && npm ci && npm run typecheck && npm run build` - Run focused TypeScript codegen tests: - `go test ./internal/codegen -run 'TestTypeScriptModel|TestTypeScriptNames|TestTypeScriptContract|TestGenerateTypeScript|TestGenerate_TypeScript|TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext' -count=1` - Run TypeScript public API compile smoke: - `cd examples/node/sdk-spike && npm ci` - `go test ./internal/codegen -run TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext -count=1` +- Run generated Node stdio tests: + - `go test ./internal/codegen -run 'TestTypeScriptGeneratedNodeServer.*OverStdio|TestTypeScriptGeneratedNodeServerRejectsInvalid(Input|Output)OverStdio' -count=1` - Run JVM compile gate: - `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` - Install JVM example scripts: From 8ee6a681f34963ea6d69b280f411a27b6e9a9ef3 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 19:50:40 +0300 Subject: [PATCH 51/74] feat(11-01): add standalone TypeScript example scaffold --- examples/8_typescript_standalone/.gitignore | 3 + examples/8_typescript_standalone/Makefile | 21 + examples/8_typescript_standalone/README.md | 49 + examples/8_typescript_standalone/easyp.lock | 1 + examples/8_typescript_standalone/easyp.yaml | 38 + .../8_typescript_standalone/package-lock.json | 1246 +++++++++++++++++ examples/8_typescript_standalone/package.json | 22 + .../proto/notebook.proto | 159 +++ .../generated/mcp/options/v1/options_pb.ts | 1053 ++++++++++++++ .../src/generated/proto/notebook_mcp.ts | 379 +++++ .../src/generated/proto/notebook_pb.ts | 218 +++ .../8_typescript_standalone/tsconfig.json | 15 + 12 files changed, 3204 insertions(+) create mode 100644 examples/8_typescript_standalone/.gitignore create mode 100644 examples/8_typescript_standalone/Makefile create mode 100644 examples/8_typescript_standalone/README.md create mode 100644 examples/8_typescript_standalone/easyp.lock create mode 100644 examples/8_typescript_standalone/easyp.yaml create mode 100644 examples/8_typescript_standalone/package-lock.json create mode 100644 examples/8_typescript_standalone/package.json create mode 100644 examples/8_typescript_standalone/proto/notebook.proto create mode 100644 examples/8_typescript_standalone/src/generated/mcp/options/v1/options_pb.ts create mode 100644 examples/8_typescript_standalone/src/generated/proto/notebook_mcp.ts create mode 100644 examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts create mode 100644 examples/8_typescript_standalone/tsconfig.json diff --git a/examples/8_typescript_standalone/.gitignore b/examples/8_typescript_standalone/.gitignore new file mode 100644 index 0000000..743a7ba --- /dev/null +++ b/examples/8_typescript_standalone/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +google/ diff --git a/examples/8_typescript_standalone/Makefile b/examples/8_typescript_standalone/Makefile new file mode 100644 index 0000000..d21ebde --- /dev/null +++ b/examples/8_typescript_standalone/Makefile @@ -0,0 +1,21 @@ +.PHONY: setup generate lint build run clean + +setup: + npm ci + +generate: setup + npm run generate + +lint: + easyp --cfg easyp.yaml mod download + easyp --cfg easyp.yaml lint -p proto -r . + +build: setup + npm run typecheck + npm run build + +run: build + npm run run + +clean: + rm -rf node_modules dist google src/generated diff --git a/examples/8_typescript_standalone/README.md b/examples/8_typescript_standalone/README.md new file mode 100644 index 0000000..d1ba5c8 --- /dev/null +++ b/examples/8_typescript_standalone/README.md @@ -0,0 +1,49 @@ +# Standalone TypeScript MCP Server + +This example is structured like a user-owned TypeScript Node project rather +than a repository fixture. It has its own `package.json`, `tsconfig.json`, +`easyp.yaml`, protobuf contract, generated TypeScript sources, and stdio +server. + +The generated code is imported as normal local ESM modules: + +```ts +import { registerNotebookAPITools } from "./generated/proto/notebook_mcp.js"; +``` + +## Generate + +```bash +make generate +``` + +`easyp.yaml` does two jobs: + +- runs `@bufbuild/protoc-gen-es` with `target=ts` and `import_extension=js` to + generate Protobuf-ES `_pb.ts` files +- runs `protoc-gen-mcp` with `lang=typescript` to generate MCP sidecars + +The checked-in local repository version uses: + +```yaml +command: ["go", "run", "../../cmd/protoc-gen-mcp"] +``` + +In an external project, replace that command with a released +`protoc-gen-mcp` binary or a pinned module entrypoint. + +## Build And Run + +```bash +make build +make run +``` + +The server exposes: + +- `notebook_CreateNote` +- `notebook_SearchNotes` +- `notebook_Health` + +Handlers implement generated Protobuf-ES types and the generated +`NotebookAPIToolHandler` interface from `src/generated/proto/notebook_mcp.ts`. diff --git a/examples/8_typescript_standalone/easyp.lock b/examples/8_typescript_standalone/easyp.lock new file mode 100644 index 0000000..c30dd0d --- /dev/null +++ b/examples/8_typescript_standalone/easyp.lock @@ -0,0 +1 @@ +github.com/easyp-tech/protoc-gen-mcp v0.4.0^{} h1:89szb16JOQw4+3VcouZEvsW8Kh6XMZi8kntiD8OOvcI= diff --git a/examples/8_typescript_standalone/easyp.yaml b/examples/8_typescript_standalone/easyp.yaml new file mode 100644 index 0000000..e4c6be4 --- /dev/null +++ b/examples/8_typescript_standalone/easyp.yaml @@ -0,0 +1,38 @@ +deps: + - github.com/easyp-tech/protoc-gen-mcp + +lint: + use: + - PACKAGE_DEFINED + - PACKAGE_LOWER_SNAKE_CASE + - PACKAGE_VERSION_SUFFIX + - FILE_LOWER_SNAKE_CASE + - MESSAGE_PASCAL_CASE + - FIELD_LOWER_SNAKE_CASE + - RPC_PASCAL_CASE + - SERVICE_PASCAL_CASE + - SERVICE_SUFFIX + - RPC_REQUEST_RESPONSE_UNIQUE + - RPC_REQUEST_STANDARD_NAME + - RPC_RESPONSE_STANDARD_NAME + - RPC_NO_CLIENT_STREAMING + - RPC_NO_SERVER_STREAMING + service_suffix: API + +generate: + inputs: + - directory: + path: proto + root: "." + plugins: + - command: ["./node_modules/.bin/protoc-gen-es"] + with_imports: true + out: src/generated + opts: + target: ts + import_extension: js + - command: ["go", "run", "../../cmd/protoc-gen-mcp"] + out: src/generated + opts: + paths: source_relative + lang: typescript diff --git a/examples/8_typescript_standalone/package-lock.json b/examples/8_typescript_standalone/package-lock.json new file mode 100644 index 0000000..d7713fe --- /dev/null +++ b/examples/8_typescript_standalone/package-lock.json @@ -0,0 +1,1246 @@ +{ + "name": "protoc-gen-mcp-typescript-standalone", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "protoc-gen-mcp-typescript-standalone", + "version": "0.0.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@modelcontextprotocol/sdk": "1.29.0", + "ajv": "8.20.0" + }, + "devDependencies": { + "@bufbuild/protoc-gen-es": "2.12.0", + "@types/node": "24.12.2", + "typescript": "6.0.3" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.12.0.tgz", + "integrity": "sha512-d9htF6jEkSwPbp9d/vSmZOBF7eeG18AvTMKmVg4I23afnrQOxL2w3WOXa9TaufMCyu24QakEUb4vux8apI5e7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@bufbuild/protoplugin": "2.12.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.12.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.12.0.tgz", + "integrity": "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/examples/8_typescript_standalone/package.json b/examples/8_typescript_standalone/package.json new file mode 100644 index 0000000..6d45005 --- /dev/null +++ b/examples/8_typescript_standalone/package.json @@ -0,0 +1,22 @@ +{ + "name": "protoc-gen-mcp-typescript-standalone", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "generate": "easyp --cfg easyp.yaml mod download && easyp --cfg easyp.yaml generate -p proto -r . && rm -rf google", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "tsc -p tsconfig.json", + "run": "node dist/server.js" + }, + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@modelcontextprotocol/sdk": "1.29.0", + "ajv": "8.20.0" + }, + "devDependencies": { + "@bufbuild/protoc-gen-es": "2.12.0", + "@types/node": "24.12.2", + "typescript": "6.0.3" + } +} diff --git a/examples/8_typescript_standalone/proto/notebook.proto b/examples/8_typescript_standalone/proto/notebook.proto new file mode 100644 index 0000000..cc06c70 --- /dev/null +++ b/examples/8_typescript_standalone/proto/notebook.proto @@ -0,0 +1,159 @@ +syntax = "proto3"; + +package standalone.notebook.v1; + +import "google/protobuf/timestamp.proto"; +import "mcp/options/v1/options.proto"; + +service NotebookAPI { + option (mcp.options.v1.service) = { + namespace: "notebook" + description: "A small in-memory notebook service for generated TypeScript MCP bindings." + }; + + rpc CreateNote(CreateNoteRequest) returns (CreateNoteResponse) { + option (mcp.options.v1.method) = { + title: "Create note" + description: "Create a note in the local in-memory notebook." + annotations: { + destructive_hint: false + idempotent_hint: false + open_world_hint: false + } + }; + } + + rpc SearchNotes(SearchNotesRequest) returns (SearchNotesResponse) { + option (mcp.options.v1.method) = { + title: "Search notes" + description: "Search notes by text and tags." + annotations: { + read_only_hint: true + open_world_hint: false + } + }; + } + + rpc Health(HealthRequest) returns (HealthResponse) { + option (mcp.options.v1.method) = { + title: "Notebook health" + description: "Verify that the notebook MCP server is alive." + annotations: { + read_only_hint: true + open_world_hint: false + } + }; + } +} + +message Note { + string id = 1 [(mcp.options.v1.field) = { + read_only: true + description: "Server-assigned note identifier." + examples: [{ string_value: "note-1" }] + }]; + + string title = 2 [(mcp.options.v1.field) = { + description: "Short human-readable note title." + min_length: 1 + max_length: 80 + examples: [{ string_value: "Ship TypeScript support" }] + }]; + + string body = 3 [(mcp.options.v1.field) = { + description: "Note body in plain text." + min_length: 1 + max_length: 2000 + examples: [{ string_value: "Verify that generated TypeScript MCP bindings are pleasant to use." }] + }]; + + repeated string tags = 4 [(mcp.options.v1.field) = { + description: "Optional tags used for filtering." + unique_items: true + examples: [ + { array_value: { items: [{ string_value: "typescript" }, { string_value: "mcp" }] } } + ] + }]; + + optional string due_date = 5 [(mcp.options.v1.field) = { + description: "Optional due date in ISO 8601 date format." + format: "date" + examples: [{ string_value: "2026-05-30" }] + }]; + + google.protobuf.Timestamp created_at = 6 [(mcp.options.v1.field) = { + read_only: true + description: "Server-assigned creation timestamp." + }]; +} + +message CreateNoteRequest { + string title = 1 [(mcp.options.v1.field) = { + description: "Short human-readable note title." + min_length: 1 + max_length: 80 + examples: [{ string_value: "Ship TypeScript support" }] + }]; + + string body = 2 [(mcp.options.v1.field) = { + description: "Note body in plain text." + min_length: 1 + max_length: 2000 + examples: [{ string_value: "Verify that generated TypeScript MCP bindings are pleasant to use." }] + }]; + + repeated string tags = 3 [(mcp.options.v1.field) = { + description: "Optional tags used for filtering." + unique_items: true + examples: [ + { array_value: { items: [{ string_value: "typescript" }, { string_value: "mcp" }] } } + ] + }]; + + optional string due_date = 4 [(mcp.options.v1.field) = { + description: "Optional due date in ISO 8601 date format." + format: "date" + examples: [{ string_value: "2026-05-30" }] + }]; +} + +message CreateNoteResponse { + Note note = 1; +} + +message SearchNotesRequest { + optional string query = 1 [(mcp.options.v1.field) = { + description: "Optional case-insensitive text query matched against title and body." + min_length: 1 + examples: [{ string_value: "typescript" }] + }]; + + repeated string tags = 2 [(mcp.options.v1.field) = { + description: "Optional tags; a note must have every requested tag." + unique_items: true + examples: [ + { array_value: { items: [{ string_value: "mcp" }] } } + ] + }]; + + optional int32 limit = 3 [(mcp.options.v1.field) = { + description: "Maximum number of notes to return." + minimum: 1.0 + maximum: 50.0 + default_value: { integer_value: 10 } + }]; +} + +message SearchNotesResponse { + repeated Note notes = 1; +} + +message HealthRequest {} + +message HealthResponse { + bool ok = 1; + int32 note_count = 2 [(mcp.options.v1.field) = { + read_only: true + description: "Current number of notes in memory." + }]; +} diff --git a/examples/8_typescript_standalone/src/generated/mcp/options/v1/options_pb.ts b/examples/8_typescript_standalone/src/generated/mcp/options/v1/options_pb.ts new file mode 100644 index 0000000..1372b36 --- /dev/null +++ b/examples/8_typescript_standalone/src/generated/mcp/options/v1/options_pb.ts @@ -0,0 +1,1053 @@ +// Package mcp.options.v1 defines custom protobuf options used by the +// protoc-gen-mcp code generator to annotate services, methods, fields, +// messages, enums, enum values, and oneofs with MCP-specific metadata. +// +// These options are the proto contract between user-authored .proto files and +// the generator. The generator reads these annotations at code-generation time +// to produce Go MCP tool bindings, JSON Schema definitions, and runtime +// registration helpers. +// +// This revision aligns the proto contract with MCP spec 2025-11-25, adding +// ToolAnnotations, Icon, OneofOptions, ExecutionOptions, hidden method +// support, read-only fields, integer examples, and optional validation +// constraints. +// +// See AGENTS.md at the repository root for the full project context, supported +// protobuf features, MVP rules, and build/test commands. + +// @generated by protoc-gen-es v2.12.0 with parameter "import_extension=js,target=ts" +// @generated from file mcp/options/v1/options.proto (package mcp.options.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenExtension, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, extDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { EnumOptions as EnumOptions$1, EnumValueOptions as EnumValueOptions$1, FieldOptions as FieldOptions$1, MessageOptions as MessageOptions$1, MethodOptions as MethodOptions$1, OneofOptions as OneofOptions$1, ServiceOptions as ServiceOptions$1 } from "../../../google/protobuf/descriptor_pb.js"; +import { file_google_protobuf_descriptor } from "../../../google/protobuf/descriptor_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file mcp/options/v1/options.proto. + */ +export const file_mcp_options_v1_options: GenFile = /*@__PURE__*/ + fileDesc("ChxtY3Avb3B0aW9ucy92MS9vcHRpb25zLnByb3RvEg5tY3Aub3B0aW9ucy52MSJdCg5TZXJ2aWNlT3B0aW9ucxIRCgluYW1lc3BhY2UYASABKAkSEwoLZGVzY3JpcHRpb24YAiABKAkSIwoFaWNvbnMYAyADKAsyFC5tY3Aub3B0aW9ucy52MS5JY29uIuEBCg1NZXRob2RPcHRpb25zEgwKBG5hbWUYASABKAkSDQoFdGl0bGUYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSDgoGaGlkZGVuGAQgASgIEjQKC2Fubm90YXRpb25zGAogASgLMh8ubWNwLm9wdGlvbnMudjEuVG9vbEFubm90YXRpb25zEiMKBWljb25zGAsgAygLMhQubWNwLm9wdGlvbnMudjEuSWNvbhIzCglleGVjdXRpb24YDCABKAsyIC5tY3Aub3B0aW9ucy52MS5FeGVjdXRpb25PcHRpb25zIsgECgxGaWVsZE9wdGlvbnMSEwoLZGVzY3JpcHRpb24YASABKAkSLgoIZXhhbXBsZXMYAyADKAsyHC5tY3Aub3B0aW9ucy52MS5FeGFtcGxlVmFsdWUSMwoNZGVmYXVsdF92YWx1ZRgEIAEoCzIcLm1jcC5vcHRpb25zLnYxLkV4YW1wbGVWYWx1ZRIPCgdwYXR0ZXJuGAogASgJEg4KBmZvcm1hdBgLIAEoCRIXCgptaW5fbGVuZ3RoGAwgASgNSACIAQESFwoKbWF4X2xlbmd0aBgNIAEoDUgBiAEBEhQKB21pbmltdW0YFCABKAFIAogBARIUCgdtYXhpbXVtGBUgASgBSAOIAQESHgoRZXhjbHVzaXZlX21pbmltdW0YFiABKAFIBIgBARIeChFleGNsdXNpdmVfbWF4aW11bRgXIAEoAUgFiAEBEhgKC211bHRpcGxlX29mGBggASgBSAaIAQESFgoJbWluX2l0ZW1zGB4gASgNSAeIAQESFgoJbWF4X2l0ZW1zGB8gASgNSAiIAQESFAoMdW5pcXVlX2l0ZW1zGCAgASgIEhEKCXJlYWRfb25seRgoIAEoCEINCgtfbWluX2xlbmd0aEINCgtfbWF4X2xlbmd0aEIKCghfbWluaW11bUIKCghfbWF4aW11bUIUChJfZXhjbHVzaXZlX21pbmltdW1CFAoSX2V4Y2x1c2l2ZV9tYXhpbXVtQg4KDF9tdWx0aXBsZV9vZkIMCgpfbWluX2l0ZW1zQgwKCl9tYXhfaXRlbXMi9wEKDEV4YW1wbGVWYWx1ZRIWCgxzdHJpbmdfdmFsdWUYASABKAlIABIWCgxudW1iZXJfdmFsdWUYAiABKAFIABIUCgpib29sX3ZhbHVlGAMgASgISAASNQoMb2JqZWN0X3ZhbHVlGAQgASgLMh0ubWNwLm9wdGlvbnMudjEuRXhhbXBsZU9iamVjdEgAEjMKC2FycmF5X3ZhbHVlGAUgASgLMhwubWNwLm9wdGlvbnMudjEuRXhhbXBsZUFycmF5SAASFAoKbnVsbF92YWx1ZRgGIAEoCEgAEhcKDWludGVnZXJfdmFsdWUYByABKANIAEIGCgRraW5kIqMBCg1FeGFtcGxlT2JqZWN0EkEKCnByb3BlcnRpZXMYASADKAsyLS5tY3Aub3B0aW9ucy52MS5FeGFtcGxlT2JqZWN0LlByb3BlcnRpZXNFbnRyeRpPCg9Qcm9wZXJ0aWVzRW50cnkSCwoDa2V5GAEgASgJEisKBXZhbHVlGAIgASgLMhwubWNwLm9wdGlvbnMudjEuRXhhbXBsZVZhbHVlOgI4ASI7CgxFeGFtcGxlQXJyYXkSKwoFaXRlbXMYASADKAsyHC5tY3Aub3B0aW9ucy52MS5FeGFtcGxlVmFsdWUitwEKD1Rvb2xBbm5vdGF0aW9ucxIWCg5yZWFkX29ubHlfaGludBgBIAEoCBIdChBkZXN0cnVjdGl2ZV9oaW50GAIgASgISACIAQESFwoPaWRlbXBvdGVudF9oaW50GAMgASgIEhwKD29wZW5fd29ybGRfaGludBgEIAEoCEgBiAEBEg0KBXRpdGxlGAUgASgJQhMKEV9kZXN0cnVjdGl2ZV9oaW50QhIKEF9vcGVuX3dvcmxkX2hpbnQiRAoESWNvbhILCgNzcmMYASABKAkSEQoJbWltZV90eXBlGAIgASgJEg0KBXNpemVzGAMgAygJEg0KBXRoZW1lGAQgASgJIkUKEEV4ZWN1dGlvbk9wdGlvbnMSMQoMdGFza19zdXBwb3J0GAEgASgOMhsubWNwLm9wdGlvbnMudjEuVGFza1N1cHBvcnQiNQoMT25lb2ZPcHRpb25zEhMKC2Rlc2NyaXB0aW9uGAEgASgJEhAKCHJlcXVpcmVkGAIgASgIImUKDk1lc3NhZ2VPcHRpb25zEg0KBXRpdGxlGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEi8KCGV4YW1wbGVzGAMgAygLMh0ubWNwLm9wdGlvbnMudjEuRXhhbXBsZU9iamVjdCIxCgtFbnVtT3B0aW9ucxINCgV0aXRsZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCSI3ChBFbnVtVmFsdWVPcHRpb25zEhMKC2Rlc2NyaXB0aW9uGAEgASgJEg4KBmhpZGRlbhgCIAEoCCpaCgtUYXNrU3VwcG9ydBIVChFUQVNLX1NVUFBPUlRfTk9ORRAAEhkKFVRBU0tfU1VQUE9SVF9PUFRJT05BTBABEhkKFVRBU0tfU1VQUE9SVF9SRVFVSVJFRBACOlsKB3NlcnZpY2USHy5nb29nbGUucHJvdG9idWYuU2VydmljZU9wdGlvbnMY+cYFIAEoCzIeLm1jcC5vcHRpb25zLnYxLlNlcnZpY2VPcHRpb25zUgdzZXJ2aWNlOlcKBm1ldGhvZBIeLmdvb2dsZS5wcm90b2J1Zi5NZXRob2RPcHRpb25zGPrGBSABKAsyHS5tY3Aub3B0aW9ucy52MS5NZXRob2RPcHRpb25zUgZtZXRob2Q6UwoFZmllbGQSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGPvGBSABKAsyHC5tY3Aub3B0aW9ucy52MS5GaWVsZE9wdGlvbnNSBWZpZWxkOlsKB21lc3NhZ2USHy5nb29nbGUucHJvdG9idWYuTWVzc2FnZU9wdGlvbnMY/MYFIAEoCzIeLm1jcC5vcHRpb25zLnYxLk1lc3NhZ2VPcHRpb25zUgdtZXNzYWdlOk8KBGVudW0SHC5nb29nbGUucHJvdG9idWYuRW51bU9wdGlvbnMY/cYFIAEoCzIbLm1jcC5vcHRpb25zLnYxLkVudW1PcHRpb25zUgRlbnVtOmQKCmVudW1fdmFsdWUSIS5nb29nbGUucHJvdG9idWYuRW51bVZhbHVlT3B0aW9ucxj+xgUgASgLMiAubWNwLm9wdGlvbnMudjEuRW51bVZhbHVlT3B0aW9uc1IJZW51bVZhbHVlOlMKBW9uZW9mEh0uZ29vZ2xlLnByb3RvYnVmLk9uZW9mT3B0aW9ucxj/xgUgASgLMhwubWNwLm9wdGlvbnMudjEuT25lb2ZPcHRpb25zUgVvbmVvZkI/Wj1naXRodWIuY29tL2Vhc3lwLXRlY2gvcHJvdG9jLWdlbi1tY3AvbWNwL29wdGlvbnMvdjE7b3B0aW9uc3YxYgZwcm90bzM", [file_google_protobuf_descriptor]); + +/** + * ServiceOptions defines MCP metadata attached to a protobuf service via + * google.protobuf.ServiceOptions. The generator uses these annotations to + * control the namespace prefix, description, and default icons for all tools + * generated for the service's unary RPC methods. + * + * @generated from message mcp.options.v1.ServiceOptions + */ +export type ServiceOptions = Message<"mcp.options.v1.ServiceOptions"> & { + /** + * namespace is prepended to every generated MCP tool name for this service, + * joined with an underscore. Dots and repeated underscores in the value are + * normalized to a single underscore. + * + * Format: plain string (not JSON-encoded). + * When absent: the generator derives the namespace from the proto service name. + * + * Example: + * option (mcp.options.v1.service) = { namespace: "myapp" }; + * // Tool names become "myapp_MethodName". + * + * @generated from field: string namespace = 1; + */ + namespace: string; + + /** + * description overrides the service-level description that the generator + * would otherwise infer from leading proto comments on the service + * declaration. + * + * Format: plain string (not JSON-encoded). + * When absent: the generator uses the proto source comment on the service. + * + * Example: + * option (mcp.options.v1.service) = { + * description: "Reporting service for analytics dashboards." + * }; + * + * @generated from field: string description = 2; + */ + description: string; + + /** + * icons provides default icons for all tools generated from this service. + * Individual method-level icons override these defaults. + * + * See: MCP spec 2025-11-25, Icon data type. + * + * @generated from field: repeated mcp.options.v1.Icon icons = 3; + */ + icons: Icon[]; +}; + +/** + * Describes the message mcp.options.v1.ServiceOptions. + * Use `create(ServiceOptionsSchema)` to create a new message. + */ +export const ServiceOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 0); + +/** + * MethodOptions defines MCP metadata attached to a protobuf RPC method via + * google.protobuf.MethodOptions. The generator uses these annotations to + * control the tool name, title, description, visibility, behavioral hints, + * icons, and execution semantics of the generated MCP tool. + * + * Deprecation: to mark an RPC as deprecated, use the standard protobuf option + * `option deprecated = true;` on the method. The generator will propagate + * this to `"deprecated": true` in the tool's JSON Schema. + * + * Method-level examples have been replaced by field-level `example` and + * `examples` annotations in FieldOptions. The generator assembles a + * composite JSON example by walking the request message's fields. + * + * @generated from message mcp.options.v1.MethodOptions + */ +export type MethodOptions = Message<"mcp.options.v1.MethodOptions"> & { + /** + * name overrides the method segment of the generated MCP tool name. + * Dots and repeated underscores in the value are normalized to a single + * underscore. The final tool name is "{namespace}_{name}". + * + * Format: plain string (not JSON-encoded). + * When absent: the generator uses the proto RPC method name as-is. + * + * Example: + * option (mcp.options.v1.method) = { name: "GenerateReport" }; + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * title sets the human-readable title of the generated MCP tool, surfaced + * to agents and clients in the tool listing. + * + * Format: plain string (not JSON-encoded). + * When absent: the tool has no explicit title; clients may display the tool + * name instead. + * + * Example: + * option (mcp.options.v1.method) = { title: "Create Report" }; + * + * @generated from field: string title = 2; + */ + title: string; + + /** + * description overrides the tool description that the generator would + * otherwise infer from leading proto comments on the RPC method. + * + * Format: plain string (not JSON-encoded). + * When absent: the generator uses the proto source comment on the RPC. + * + * Example: + * option (mcp.options.v1.method) = { + * description: "Generates a PDF report for the given date range." + * }; + * + * @generated from field: string description = 3; + */ + description: string; + + /** + * hidden suppresses tool generation for this RPC method. + * The method will exist in protobuf but will not be exposed as an MCP tool. + * Use this when the RPC should remain in the proto contract for + * compatibility but must not be called by agents. + * + * Example: + * rpc InternalDebug(DebugRequest) returns (DebugResponse) { + * option (mcp.options.v1.method) = { hidden: true }; + * }; + * + * @generated from field: bool hidden = 4; + */ + hidden: boolean; + + /** + * annotations provides behavioral hints for the generated MCP tool. + * Agents use these hints to decide confirmation prompts, safety checks, + * and other UX affordances. All annotation properties are advisory. + * + * See: MCP spec 2025-11-25, ToolAnnotations. + * + * Example: + * option (mcp.options.v1.method) = { + * annotations: { read_only_hint: true } + * }; + * + * @generated from field: mcp.options.v1.ToolAnnotations annotations = 10; + */ + annotations?: ToolAnnotations | undefined; + + /** + * icons provides visual identifiers for the generated MCP tool. + * Overrides service-level default icons for this specific method. + * + * See: MCP spec 2025-11-25, Icon data type. + * + * @generated from field: repeated mcp.options.v1.Icon icons = 11; + */ + icons: Icon[]; + + /** + * execution describes execution-related properties for the tool, + * such as task-augmented execution support. + * + * See: MCP spec 2025-11-25, execution.taskSupport. + * + * Example: + * option (mcp.options.v1.method) = { + * execution: { task_support: TASK_SUPPORT_OPTIONAL } + * }; + * + * @generated from field: mcp.options.v1.ExecutionOptions execution = 12; + */ + execution?: ExecutionOptions | undefined; +}; + +/** + * Describes the message mcp.options.v1.MethodOptions. + * Use `create(MethodOptionsSchema)` to create a new message. + */ +export const MethodOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 1); + +/** + * FieldOptions defines MCP metadata attached to a protobuf field via + * google.protobuf.FieldOptions. The generator uses these annotations to enrich + * the JSON Schema of individual fields with descriptions, examples, defaults, + * and validation constraints. + * + * Requiredness policy: field requiredness in the generated MCP JSON Schema is + * determined entirely by proto3 syntax — there is no override mechanism in + * FieldOptions. + * - A singular field WITHOUT the `optional` keyword is required. + * - A singular field WITH the `optional` keyword is optional. + * - `repeated`, `map`, and `oneof` fields are always optional. + * + * @generated from message mcp.options.v1.FieldOptions + */ +export type FieldOptions = Message<"mcp.options.v1.FieldOptions"> & { + /** + * description overrides the field description that the generator would + * otherwise infer from leading proto comments on the field declaration. + * + * Format: plain string (not JSON-encoded). + * When absent: the generator uses the proto source comment on the field. + * + * Example: + * string name = 1 [(mcp.options.v1.field) = { + * description: "Full name of the report recipient." + * }]; + * + * @generated from field: string description = 1; + */ + description: string; + + /** + * examples provides multiple typed example values for this field, + * included in the field's JSON Schema as `"examples": [, ...]`. + * + * When absent: the generator may synthesize fallback examples for complex + * ProtoJSON shapes. + * + * Example: + * repeated string tags = 3 [(mcp.options.v1.field) = { + * examples: [ + * { array_value: { items: [{ string_value: "urgent" }] } } + * ] + * }]; + * + * @generated from field: repeated mcp.options.v1.ExampleValue examples = 3; + */ + examples: ExampleValue[]; + + /** + * default_value provides a typed default value for this field, + * included in the field's JSON Schema as `"default": `. + * + * When absent: no `"default"` is emitted in the JSON Schema. + * + * Example: + * int32 page_size = 4 [(mcp.options.v1.field) = { + * default_value: { number_value: 25 } + * }]; + * + * @generated from field: mcp.options.v1.ExampleValue default_value = 4; + */ + defaultValue?: ExampleValue | undefined; + + /** + * pattern defines a regular expression that the string must match. + * Example: "^[A-Z][a-z]+$" for capitalized words + * + * @generated from field: string pattern = 10; + */ + pattern: string; + + /** + * format defines a semantic format for the string according to JSON Schema. + * + * Built-in JSON Schema formats natively understood by most validators include: + * - "date-time", "date", "time", "duration" + * - "email", "idn-email" + * - "hostname", "idn-hostname" + * - "ipv4", "ipv6" + * - "uri", "uri-reference", "iri", "iri-reference" + * - "uuid" + * - "regex" + * - "json-pointer", "relative-json-pointer" + * + * Custom formats are also fully supported. Since MCP schemas are consumed + * by LLMs, providing a descriptive custom format (e.g., "cron-expression", + * "hex-color", "sql-query") acts as a strong semantic hint for tool payload + * generation, even if the format is not strictly validated by JSON Schema. + * + * See: https://json-schema.org/understanding-json-schema/reference/string.html#format + * + * @generated from field: string format = 11; + */ + format: string; + + /** + * min_length limits the minimum string length (inclusive). + * Uses optional to distinguish "not set" from zero. + * Example: min_length: 1 means non-empty string + * + * @generated from field: optional uint32 min_length = 12; + */ + minLength?: number | undefined; + + /** + * max_length limits the maximum string length (inclusive). + * Uses optional to distinguish "not set" from zero. + * Example: max_length: 100 + * + * @generated from field: optional uint32 max_length = 13; + */ + maxLength?: number | undefined; + + /** + * minimum defines the inclusive lower bound for numeric values. + * Uses optional to correctly support minimum: 0. + * Example: minimum: 0.0 for non-negative numbers + * + * @generated from field: optional double minimum = 20; + */ + minimum?: number | undefined; + + /** + * maximum defines the inclusive upper bound for numeric values. + * Uses optional to correctly support maximum: 0. + * Example: maximum: 100.0 + * + * @generated from field: optional double maximum = 21; + */ + maximum?: number | undefined; + + /** + * exclusive_minimum defines the strictly greater than lower bound. + * Uses optional to correctly support exclusive_minimum: 0. + * Example: exclusive_minimum: 0.0 means value must be > 0 + * + * @generated from field: optional double exclusive_minimum = 22; + */ + exclusiveMinimum?: number | undefined; + + /** + * exclusive_maximum defines the strictly less than upper bound. + * Uses optional to correctly support exclusive_maximum: 0. + * Example: exclusive_maximum: 100.0 means value must be < 100 + * + * @generated from field: optional double exclusive_maximum = 23; + */ + exclusiveMaximum?: number | undefined; + + /** + * multiple_of requires the numeric value to be a multiple of this number. + * Uses optional to distinguish "not set" from zero. + * Example: multiple_of: 0.01 for monetary values (cents precision) + * + * @generated from field: optional double multiple_of = 24; + */ + multipleOf?: number | undefined; + + /** + * min_items limits the minimum number of items in a repeated field. + * Uses optional to distinguish "not set" from zero. + * Example: min_items: 1 means non-empty array + * + * @generated from field: optional uint32 min_items = 30; + */ + minItems?: number | undefined; + + /** + * max_items limits the maximum number of items in a repeated field. + * Uses optional to distinguish "not set" from zero. + * Example: max_items: 10 + * + * @generated from field: optional uint32 max_items = 31; + */ + maxItems?: number | undefined; + + /** + * unique_items indicates whether array elements must be unique. + * Example: unique_items: true for a set-like collection + * + * @generated from field: bool unique_items = 32; + */ + uniqueItems: boolean; + + /** + * read_only marks the field as read-only in JSON Schema. + * Read-only fields are emitted with `"readOnly": true` in the JSON Schema. + * Agents should not include read-only fields in tool call arguments. + * + * Example: + * string id = 1 [(mcp.options.v1.field) = { read_only: true }]; + * + * @generated from field: bool read_only = 40; + */ + readOnly: boolean; +}; + +/** + * Describes the message mcp.options.v1.FieldOptions. + * Use `create(FieldOptionsSchema)` to create a new message. + */ +export const FieldOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 2); + +/** + * ExampleValue represents a typed JSON value for examples and defaults + * without relying on google.protobuf.Value to keep the closure clean. + * + * Usage examples: + * - String: { string_value: "example" } + * - Number: { number_value: 42.5 } + * - Integer: { integer_value: 42 } + * - Boolean: { bool_value: true } + * - Null: { null_value: true } + * - Object: { object_value: { properties: { "key": { string_value: "val" } } } } + * - Array: { array_value: { items: [{ number_value: 1 }, { number_value: 2 }] } } + * + * @generated from message mcp.options.v1.ExampleValue + */ +export type ExampleValue = Message<"mcp.options.v1.ExampleValue"> & { + /** + * kind represents the underlying type of the example value. + * + * @generated from oneof mcp.options.v1.ExampleValue.kind + */ + kind: { + /** + * string_value emits a JSON string. + * + * @generated from field: string string_value = 1; + */ + value: string; + case: "stringValue"; + } | { + /** + * number_value emits a JSON number (double precision). + * + * @generated from field: double number_value = 2; + */ + value: number; + case: "numberValue"; + } | { + /** + * bool_value emits a JSON boolean. + * + * @generated from field: bool bool_value = 3; + */ + value: boolean; + case: "boolValue"; + } | { + /** + * object_value emits a JSON object. + * + * @generated from field: mcp.options.v1.ExampleObject object_value = 4; + */ + value: ExampleObject; + case: "objectValue"; + } | { + /** + * array_value emits a JSON array. + * + * @generated from field: mcp.options.v1.ExampleArray array_value = 5; + */ + value: ExampleArray; + case: "arrayValue"; + } | { + /** + * null_value: set to true to emit an explicit JSON null. + * + * @generated from field: bool null_value = 6; + */ + value: boolean; + case: "nullValue"; + } | { + /** + * integer_value emits a JSON integer without floating-point precision loss. + * Use this for exact integer examples (e.g., 42 instead of 42.0). + * + * @generated from field: int64 integer_value = 7; + */ + value: bigint; + case: "integerValue"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message mcp.options.v1.ExampleValue. + * Use `create(ExampleValueSchema)` to create a new message. + */ +export const ExampleValueSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 3); + +/** + * ExampleObject represents a JSON object structure for examples. + * Properties are stored as a map from string keys to typed values. + * + * Example: + * { + * properties: { + * "name": { string_value: "John" }, + * "age": { number_value: 30 } + * } + * } + * + * @generated from message mcp.options.v1.ExampleObject + */ +export type ExampleObject = Message<"mcp.options.v1.ExampleObject"> & { + /** + * properties is a map of property names to typed values. + * + * @generated from field: map properties = 1; + */ + properties: { [key: string]: ExampleValue }; +}; + +/** + * Describes the message mcp.options.v1.ExampleObject. + * Use `create(ExampleObjectSchema)` to create a new message. + */ +export const ExampleObjectSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 4); + +/** + * ExampleArray represents a JSON array structure for examples. + * Items are stored as a repeated list of typed values. + * + * Example: + * { + * items: [ + * { string_value: "foo" }, + * { string_value: "bar" } + * ] + * } + * + * @generated from message mcp.options.v1.ExampleArray + */ +export type ExampleArray = Message<"mcp.options.v1.ExampleArray"> & { + /** + * items is the list of array elements. + * + * @generated from field: repeated mcp.options.v1.ExampleValue items = 1; + */ + items: ExampleValue[]; +}; + +/** + * Describes the message mcp.options.v1.ExampleArray. + * Use `create(ExampleArraySchema)` to create a new message. + */ +export const ExampleArraySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 5); + +/** + * ToolAnnotations provides optional behavioral hints for a generated MCP tool. + * All fields are advisory — agents may choose to ignore them. + * + * See: MCP spec 2025-11-25, ToolAnnotations data type. + * + * Example: + * option (mcp.options.v1.method) = { + * annotations: { + * read_only_hint: true + * open_world_hint: false + * } + * }; + * + * @generated from message mcp.options.v1.ToolAnnotations + */ +export type ToolAnnotations = Message<"mcp.options.v1.ToolAnnotations"> & { + /** + * read_only_hint: if true, the tool does not modify its environment. + * Default: false. + * + * Example: a search or lookup tool that only reads data. + * + * @generated from field: bool read_only_hint = 1; + */ + readOnlyHint: boolean; + + /** + * destructive_hint: if true, the tool may perform destructive updates + * to its environment. If false, the tool performs only additive updates. + * Meaningful only when read_only_hint is false. + * + * Uses optional because the MCP default is true (destructive assumed), + * but proto3 zero-value for bool is false — we must distinguish "unset" + * from "explicitly false". + * + * Default: true (when unset, assume destructive). + * + * @generated from field: optional bool destructive_hint = 2; + */ + destructiveHint?: boolean | undefined; + + /** + * idempotent_hint: if true, calling the tool repeatedly with the same + * arguments will have no additional effect on its environment. + * Meaningful only when read_only_hint is false. + * Default: false. + * + * @generated from field: bool idempotent_hint = 3; + */ + idempotentHint: boolean; + + /** + * open_world_hint: if true, this tool may interact with an "open world" + * of external entities. If false, the tool's domain of interaction is + * closed. For example, a web search tool is open-world, whereas a + * memory tool is closed-world. + * + * Uses optional because the MCP default is true (open-world assumed), + * but proto3 zero-value for bool is false. + * + * Default: true (when unset, assume open-world). + * + * @generated from field: optional bool open_world_hint = 4; + */ + openWorldHint?: boolean | undefined; + + /** + * title provides a human-readable title via annotations. + * If MethodOptions.title is set, it takes precedence at the top level. + * + * @generated from field: string title = 5; + */ + title: string; +}; + +/** + * Describes the message mcp.options.v1.ToolAnnotations. + * Use `create(ToolAnnotationsSchema)` to create a new message. + */ +export const ToolAnnotationsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 6); + +/** + * Icon represents a visual identifier for an MCP entity (tool, service), + * as defined by the MCP spec Icon data type. + * + * See: MCP spec 2025-11-25, Icon. + * + * Example: + * icons: [{ + * src: "https://example.com/tool-icon.png" + * mime_type: "image/png" + * sizes: ["48x48"] + * }] + * + * @generated from message mcp.options.v1.Icon + */ +export type Icon = Message<"mcp.options.v1.Icon"> & { + /** + * src is a URI pointing to the icon resource (required). + * Must be an HTTPS URL or a data: URI with base64-encoded image data. + * + * @generated from field: string src = 1; + */ + src: string; + + /** + * mime_type provides the MIME type of the icon (optional). + * Examples: "image/png", "image/svg+xml", "image/webp". + * + * @generated from field: string mime_type = 2; + */ + mimeType: string; + + /** + * sizes provides size specifications (optional). + * Examples: ["48x48"], ["any"] for scalable formats like SVG, + * or ["48x48", "96x96"] for multiple sizes. + * + * @generated from field: repeated string sizes = 3; + */ + sizes: string[]; + + /** + * theme specifies the intended background theme for this icon (optional). + * Values: "light" or "dark". + * + * @generated from field: string theme = 4; + */ + theme: string; +}; + +/** + * Describes the message mcp.options.v1.Icon. + * Use `create(IconSchema)` to create a new message. + */ +export const IconSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 7); + +/** + * ExecutionOptions describes execution-related properties for a generated + * MCP tool. + * + * See: MCP spec 2025-11-25, execution.taskSupport. + * + * @generated from message mcp.options.v1.ExecutionOptions + */ +export type ExecutionOptions = Message<"mcp.options.v1.ExecutionOptions"> & { + /** + * task_support indicates whether this tool supports task-augmented execution. + * See: MCP spec 2025-11-25, Tool-Level Negotiation. + * + * Default: TASK_SUPPORT_FORBIDDEN. + * + * @generated from field: mcp.options.v1.TaskSupport task_support = 1; + */ + taskSupport: TaskSupport; +}; + +/** + * Describes the message mcp.options.v1.ExecutionOptions. + * Use `create(ExecutionOptionsSchema)` to create a new message. + */ +export const ExecutionOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 8); + +/** + * OneofOptions defines MCP metadata attached to a protobuf oneof group via + * google.protobuf.OneofOptions. The generator uses these annotations to + * enrich JSON Schema constraints for oneof groups. + * + * Example: + * oneof selector { + * option (mcp.options.v1.oneof) = { + * description: "Choose one identification method." + * required: true + * }; + * string email = 1; + * string phone = 2; + * } + * + * @generated from message mcp.options.v1.OneofOptions + */ +export type OneofOptions = Message<"mcp.options.v1.OneofOptions"> & { + /** + * description provides a human-readable description of the oneof group, + * surfaced in the JSON Schema or tool documentation. + * + * When absent: no description is emitted for the oneof group. + * + * @generated from field: string description = 1; + */ + description: string; + + /** + * required: if true, at least one variant of the oneof must be provided + * in the tool call arguments. By default, oneof groups are optional in the + * generated MCP schema — the caller may omit all oneof variants. + * + * Default: false (all variants optional). + * + * @generated from field: bool required = 2; + */ + required: boolean; +}; + +/** + * Describes the message mcp.options.v1.OneofOptions. + * Use `create(OneofOptionsSchema)` to create a new message. + */ +export const OneofOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 9); + +/** + * MessageOptions defines MCP metadata attached to a protobuf message via + * google.protobuf.MessageOptions. The generator uses these annotations to + * enrich the JSON Schema of nested message objects with a title, description, + * and examples. + * + * @generated from message mcp.options.v1.MessageOptions + */ +export type MessageOptions = Message<"mcp.options.v1.MessageOptions"> & { + /** + * title sets the human-readable title for this message's JSON Schema object, + * surfaced to agents and clients inspecting the tool's input schema. + * + * Format: plain string (not JSON-encoded). + * When absent: no `"title"` is emitted in the JSON Schema object. + * + * Example: + * option (mcp.options.v1.message) = { title: "Report Parameters" }; + * + * @generated from field: string title = 1; + */ + title: string; + + /** + * description provides a human-readable description for this message's + * JSON Schema object. + * + * Format: plain string (not JSON-encoded). + * When absent: the generator may use the proto source comment on the message. + * + * Example: + * option (mcp.options.v1.message) = { + * description: "Parameters controlling report generation." + * }; + * + * @generated from field: string description = 2; + */ + description: string; + + /** + * examples provides typed object examples for this message, included + * in the message's JSON Schema as `"examples": [, ...]`. + * + * When absent: no message-level examples are emitted. + * + * Example: + * option (mcp.options.v1.message) = { + * examples: [{ properties: { "name": { string_value: "Q4 Report" } } }] + * }; + * + * @generated from field: repeated mcp.options.v1.ExampleObject examples = 3; + */ + examples: ExampleObject[]; +}; + +/** + * Describes the message mcp.options.v1.MessageOptions. + * Use `create(MessageOptionsSchema)` to create a new message. + */ +export const MessageOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 10); + +/** + * EnumOptions defines MCP metadata attached to a protobuf enum via + * google.protobuf.EnumOptions. The generator uses these annotations to enrich + * the JSON Schema of enum types with a title and description. + * + * @generated from message mcp.options.v1.EnumOptions + */ +export type EnumOptions = Message<"mcp.options.v1.EnumOptions"> & { + /** + * title sets the human-readable title for this enum's JSON Schema + * representation. + * + * Format: plain string (not JSON-encoded). + * When absent: no `"title"` is emitted in the JSON Schema. + * + * Example: + * option (mcp.options.v1.enum) = { title: "Report Format" }; + * + * @generated from field: string title = 1; + */ + title: string; + + /** + * description provides a human-readable description for this enum's + * JSON Schema representation. + * + * Format: plain string (not JSON-encoded). + * When absent: the generator may use the proto source comment on the enum. + * + * Example: + * option (mcp.options.v1.enum) = { + * description: "Output format for generated reports." + * }; + * + * @generated from field: string description = 2; + */ + description: string; +}; + +/** + * Describes the message mcp.options.v1.EnumOptions. + * Use `create(EnumOptionsSchema)` to create a new message. + */ +export const EnumOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 11); + +/** + * EnumValueOptions defines MCP metadata attached to an individual protobuf + * enum value via google.protobuf.EnumValueOptions. The generator uses these + * annotations to control per-value descriptions and visibility in the + * generated JSON Schema. + * + * A common pattern is to hide sentinel zero-values such as `_UNSPECIFIED` or + * `_NONE` from the JSON Schema's allowed values list by setting + * `hidden = true`. This keeps the schema agent-friendly while preserving the + * proto3 zero-value convention: + * + * enum Status { + * STATUS_NONE = 0 [(mcp.options.v1.enum_value) = { + * hidden: true + * }]; + * STATUS_ACTIVE = 1; + * } + * + * @generated from message mcp.options.v1.EnumValueOptions + */ +export type EnumValueOptions = Message<"mcp.options.v1.EnumValueOptions"> & { + /** + * description provides a human-readable description for this enum value, + * included in the JSON Schema alongside the value. + * + * Format: plain string (not JSON-encoded). + * When absent: no description is emitted for this enum value. + * + * Example: + * STATUS_ACTIVE = 1 [(mcp.options.v1.enum_value) = { + * description: "The resource is active and available." + * }]; + * + * @generated from field: string description = 1; + */ + description: string; + + /** + * hidden excludes this enum value from the JSON Schema's list of allowed + * values. Typically used for sentinel zero-values (`_UNSPECIFIED`, `_NONE`) + * that should not be presented to agents or clients. + * + * Format: boolean. + * When absent (false): the enum value is included in the schema. + * + * Example: + * STATUS_NONE = 0 [(mcp.options.v1.enum_value) = { + * hidden: true + * }]; + * + * @generated from field: bool hidden = 2; + */ + hidden: boolean; +}; + +/** + * Describes the message mcp.options.v1.EnumValueOptions. + * Use `create(EnumValueOptionsSchema)` to create a new message. + */ +export const EnumValueOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_mcp_options_v1_options, 12); + +/** + * TaskSupport enumerates the supported modes for task-augmented tool execution. + * + * See: MCP spec 2025-11-25, execution.taskSupport. + * + * @generated from enum mcp.options.v1.TaskSupport + */ +export enum TaskSupport { + /** + * TASK_SUPPORT_NONE is the default value, equivalent to MCP's + * "forbidden" — this tool must not be invoked as a task. + * + * @generated from enum value: TASK_SUPPORT_NONE = 0; + */ + NONE = 0, + + /** + * TASK_SUPPORT_OPTIONAL means clients may invoke this tool as a task + * or as a normal request. + * + * @generated from enum value: TASK_SUPPORT_OPTIONAL = 1; + */ + OPTIONAL = 1, + + /** + * TASK_SUPPORT_REQUIRED means clients must invoke this tool as a task. + * The server will return a -32601 error for non-task invocations. + * + * @generated from enum value: TASK_SUPPORT_REQUIRED = 2; + */ + REQUIRED = 2, +} + +/** + * Describes the enum mcp.options.v1.TaskSupport. + */ +export const TaskSupportSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_mcp_options_v1_options, 0); + +/** + * service stores MCP metadata for a protobuf service. + * + * @generated from extension: mcp.options.v1.ServiceOptions service = 91001; + */ +export const service: GenExtension = /*@__PURE__*/ + extDesc(file_mcp_options_v1_options, 0); + +/** + * method stores MCP metadata for a protobuf RPC method. + * + * @generated from extension: mcp.options.v1.MethodOptions method = 91002; + */ +export const method: GenExtension = /*@__PURE__*/ + extDesc(file_mcp_options_v1_options, 1); + +/** + * field stores MCP metadata for a protobuf field. + * + * @generated from extension: mcp.options.v1.FieldOptions field = 91003; + */ +export const field: GenExtension = /*@__PURE__*/ + extDesc(file_mcp_options_v1_options, 2); + +/** + * message stores MCP metadata for a protobuf message. + * + * @generated from extension: mcp.options.v1.MessageOptions message = 91004; + */ +export const message: GenExtension = /*@__PURE__*/ + extDesc(file_mcp_options_v1_options, 3); + +/** + * enum stores MCP metadata for a protobuf enum. + * + * @generated from extension: mcp.options.v1.EnumOptions enum = 91005; + */ +export const enum$: GenExtension = /*@__PURE__*/ + extDesc(file_mcp_options_v1_options, 4); + +/** + * enum_value stores MCP metadata for a protobuf enum value. + * + * @generated from extension: mcp.options.v1.EnumValueOptions enum_value = 91006; + */ +export const enum_value: GenExtension = /*@__PURE__*/ + extDesc(file_mcp_options_v1_options, 5); + +/** + * oneof stores MCP metadata for a protobuf oneof group. + * + * @generated from extension: mcp.options.v1.OneofOptions oneof = 91007; + */ +export const oneof: GenExtension = /*@__PURE__*/ + extDesc(file_mcp_options_v1_options, 6); + diff --git a/examples/8_typescript_standalone/src/generated/proto/notebook_mcp.ts b/examples/8_typescript_standalone/src/generated/proto/notebook_mcp.ts new file mode 100644 index 0000000..fe0dc92 --- /dev/null +++ b/examples/8_typescript_standalone/src/generated/proto/notebook_mcp.ts @@ -0,0 +1,379 @@ +// Code generated by protoc-gen-mcp. DO NOT EDIT. +// source: proto/notebook.proto + +import { createRegistry, fromJson, toJson } from "@bufbuild/protobuf"; +import type { DescFile, DescMessage, MessageShape } from "@bufbuild/protobuf"; +import type { JsonValue, Registry } from "@bufbuild/protobuf"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult, ServerNotification, ServerRequest, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { ValidateFunction } from "ajv/dist/2020.js"; + +import type { CreateNoteRequest, CreateNoteResponse, HealthRequest, HealthResponse, SearchNotesRequest, SearchNotesResponse } from "./notebook_pb.js"; +import { CreateNoteRequestSchema, CreateNoteResponseSchema, HealthRequestSchema, HealthResponseSchema, SearchNotesRequestSchema, SearchNotesResponseSchema, file_proto_notebook } from "./notebook_pb.js"; + +export type ToolRequestContext = RequestHandlerExtra; + +type ToolAnnotationsMetadata = { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + title?: string; +}; + +type ToolIconMetadata = { + src: string; + mimeType?: string; + sizes?: string[]; + theme?: string; +}; + +type ToolExecutionMetadata = { + taskSupport: "optional" | "required"; +}; + +type RegisteredTool = { + name: string; + title?: string; + description?: string; + impl: unknown; + handler: (ctx: ToolRequestContext, request: never) => unknown | Promise; + inputSchemaJson: string; + outputSchemaJson: string; + inputSchema: DescMessage; + outputSchema: DescMessage; + fileRegistry: DescFile; + inputFileRegistry: DescFile; + outputFileRegistry: DescFile; + registry: Registry; + validateInput: ValidateFunction; + validateOutput: ValidateFunction; + annotations?: ToolAnnotationsMetadata; + icons: ToolIconMetadata[]; + execution?: ToolExecutionMetadata; +}; + +type ServerToolRegistry = { + tools: RegisteredTool[]; + handlersInstalled: boolean; +}; + +export interface NotebookAPIToolHandler { + createNote( + ctx: ToolRequestContext, + request: CreateNoteRequest, + ): CreateNoteResponse | Promise; + searchNotes( + ctx: ToolRequestContext, + request: SearchNotesRequest, + ): SearchNotesResponse | Promise; + health( + ctx: ToolRequestContext, + request: HealthRequest, + ): HealthResponse | Promise; +} + +export const NOTEBOOK_API_CREATE_NOTE_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"body\":{\"type\":\"string\",\"description\":\"Note body in plain text.\",\"examples\":[\"Verify that generated TypeScript MCP bindings are pleasant to use.\"],\"minLength\":1,\"maxLength\":2000},\"dueDate\":{\"type\":[\"string\",\"null\"],\"description\":\"Optional due date in ISO 8601 date format.\",\"examples\":[\"2026-05-30\"],\"format\":\"date\"},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"typescript\",\"mcp\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable note title.\",\"examples\":[\"Ship TypeScript support\"],\"minLength\":1,\"maxLength\":80}},\"examples\":[{\"body\":\"example\",\"dueDate\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"title\",\"body\"],\"additionalProperties\":false}"; +export const NOTEBOOK_API_CREATE_NOTE_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"note\":{\"type\":\"object\",\"properties\":{\"body\":{\"type\":\"string\",\"description\":\"Note body in plain text.\",\"examples\":[\"Verify that generated TypeScript MCP bindings are pleasant to use.\"],\"minLength\":1,\"maxLength\":2000},\"createdAt\":{\"type\":\"string\",\"description\":\"Server-assigned creation timestamp.\",\"readOnly\":true,\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"dueDate\":{\"type\":[\"string\",\"null\"],\"description\":\"Optional due date in ISO 8601 date format.\",\"examples\":[\"2026-05-30\"],\"format\":\"date\"},\"id\":{\"type\":\"string\",\"description\":\"Server-assigned note identifier.\",\"readOnly\":true,\"examples\":[\"note-1\"]},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"typescript\",\"mcp\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable note title.\",\"examples\":[\"Ship TypeScript support\"],\"minLength\":1,\"maxLength\":80}},\"examples\":[{\"body\":\"example\",\"createdAt\":\"2026-03-09T10:11:12Z\",\"dueDate\":\"example\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"id\",\"title\",\"body\",\"createdAt\"],\"additionalProperties\":false}},\"examples\":[{\"note\":{\"body\":\"example\",\"createdAt\":\"2026-03-09T10:11:12Z\",\"dueDate\":\"example\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}}],\"required\":[\"note\"],\"additionalProperties\":false}"; + +export const NOTEBOOK_API_SEARCH_NOTES_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"limit\":{\"type\":[\"integer\",\"null\"],\"description\":\"Maximum number of notes to return.\",\"examples\":[-1],\"minimum\":1,\"maximum\":50},\"query\":{\"type\":[\"string\",\"null\"],\"description\":\"Optional case-insensitive text query matched against title and body.\",\"examples\":[\"typescript\"],\"minLength\":1},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags; a note must have every requested tag.\",\"examples\":[\"example\"]},\"description\":\"Optional tags; a note must have every requested tag.\",\"examples\":[[\"mcp\"]],\"uniqueItems\":true}},\"examples\":[{\"limit\":-1,\"query\":\"example\",\"tags\":[\"example\"]}],\"additionalProperties\":false}"; +export const NOTEBOOK_API_SEARCH_NOTES_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"notes\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"object\",\"properties\":{\"body\":{\"type\":\"string\",\"description\":\"Note body in plain text.\",\"examples\":[\"Verify that generated TypeScript MCP bindings are pleasant to use.\"],\"minLength\":1,\"maxLength\":2000},\"createdAt\":{\"type\":\"string\",\"description\":\"Server-assigned creation timestamp.\",\"readOnly\":true,\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"dueDate\":{\"type\":[\"string\",\"null\"],\"description\":\"Optional due date in ISO 8601 date format.\",\"examples\":[\"2026-05-30\"],\"format\":\"date\"},\"id\":{\"type\":\"string\",\"description\":\"Server-assigned note identifier.\",\"readOnly\":true,\"examples\":[\"note-1\"]},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"typescript\",\"mcp\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable note title.\",\"examples\":[\"Ship TypeScript support\"],\"minLength\":1,\"maxLength\":80}},\"examples\":[{\"body\":\"example\",\"createdAt\":\"2026-03-09T10:11:12Z\",\"dueDate\":\"example\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"id\",\"title\",\"body\",\"createdAt\"],\"additionalProperties\":false},\"examples\":[[{\"body\":\"example\",\"createdAt\":\"2026-03-09T10:11:12Z\",\"dueDate\":\"example\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}]]}},\"examples\":[{\"notes\":[{\"body\":\"example\",\"createdAt\":\"2026-03-09T10:11:12Z\",\"dueDate\":\"example\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}]}],\"additionalProperties\":false}"; + +export const NOTEBOOK_API_HEALTH_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"additionalProperties\":false}"; +export const NOTEBOOK_API_HEALTH_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"noteCount\":{\"type\":\"integer\",\"description\":\"Current number of notes in memory.\",\"readOnly\":true,\"examples\":[-1]},\"ok\":{\"type\":\"boolean\",\"examples\":[true]}},\"examples\":[{\"noteCount\":-1,\"ok\":true}],\"required\":[\"ok\",\"noteCount\"],\"additionalProperties\":false}"; + +export function registerNotebookAPITools( + server: Server, + impl: NotebookAPIToolHandler, + namespace?: string | null, +): void { + const registry = getToolRegistry(server); + installMcpHandlers(server, registry); + registry.tools.push( + { + name: resolveToolName(namespace, "CreateNote", "notebook"), + title: "Create note", + description: "Create a note in the local in-memory notebook.", + impl, + handler: impl.createNote.bind(impl), + inputSchemaJson: NOTEBOOK_API_CREATE_NOTE_INPUT_SCHEMA_JSON, + outputSchemaJson: NOTEBOOK_API_CREATE_NOTE_OUTPUT_SCHEMA_JSON, + inputSchema: CreateNoteRequestSchema, + outputSchema: CreateNoteResponseSchema, + fileRegistry: file_proto_notebook, + inputFileRegistry: file_proto_notebook, + outputFileRegistry: file_proto_notebook, + registry: buildProtoRegistry(file_proto_notebook, file_proto_notebook, file_proto_notebook), + validateInput: compileSchema(NOTEBOOK_API_CREATE_NOTE_INPUT_SCHEMA_JSON), + validateOutput: compileSchema(NOTEBOOK_API_CREATE_NOTE_OUTPUT_SCHEMA_JSON), + annotations: { + destructiveHint: false, + openWorldHint: false, + }, + icons: [ + ], + execution: undefined, + }, + { + name: resolveToolName(namespace, "SearchNotes", "notebook"), + title: "Search notes", + description: "Search notes by text and tags.", + impl, + handler: impl.searchNotes.bind(impl), + inputSchemaJson: NOTEBOOK_API_SEARCH_NOTES_INPUT_SCHEMA_JSON, + outputSchemaJson: NOTEBOOK_API_SEARCH_NOTES_OUTPUT_SCHEMA_JSON, + inputSchema: SearchNotesRequestSchema, + outputSchema: SearchNotesResponseSchema, + fileRegistry: file_proto_notebook, + inputFileRegistry: file_proto_notebook, + outputFileRegistry: file_proto_notebook, + registry: buildProtoRegistry(file_proto_notebook, file_proto_notebook, file_proto_notebook), + validateInput: compileSchema(NOTEBOOK_API_SEARCH_NOTES_INPUT_SCHEMA_JSON), + validateOutput: compileSchema(NOTEBOOK_API_SEARCH_NOTES_OUTPUT_SCHEMA_JSON), + annotations: { + readOnlyHint: true, + openWorldHint: false, + }, + icons: [ + ], + execution: undefined, + }, + { + name: resolveToolName(namespace, "Health", "notebook"), + title: "Notebook health", + description: "Verify that the notebook MCP server is alive.", + impl, + handler: impl.health.bind(impl), + inputSchemaJson: NOTEBOOK_API_HEALTH_INPUT_SCHEMA_JSON, + outputSchemaJson: NOTEBOOK_API_HEALTH_OUTPUT_SCHEMA_JSON, + inputSchema: HealthRequestSchema, + outputSchema: HealthResponseSchema, + fileRegistry: file_proto_notebook, + inputFileRegistry: file_proto_notebook, + outputFileRegistry: file_proto_notebook, + registry: buildProtoRegistry(file_proto_notebook, file_proto_notebook, file_proto_notebook), + validateInput: compileSchema(NOTEBOOK_API_HEALTH_INPUT_SCHEMA_JSON), + validateOutput: compileSchema(NOTEBOOK_API_HEALTH_OUTPUT_SCHEMA_JSON), + annotations: { + readOnlyHint: true, + openWorldHint: false, + }, + icons: [ + ], + execution: undefined, + }, + ); +} + +const toolRegistrySymbol: unique symbol = Symbol.for("protoc-gen-mcp.typescript.toolRegistry") as never; +const jsonSchemaValidator = new Ajv2020({ strict: false, allErrors: true }); + +type ServerWithGeneratedToolRegistry = Server & { + [toolRegistrySymbol]?: ServerToolRegistry; +}; + +function getToolRegistry(server: Server): ServerToolRegistry { + const holder = server as ServerWithGeneratedToolRegistry; + let registry = holder[toolRegistrySymbol]; + if (registry === undefined) { + registry = { tools: [], handlersInstalled: false }; + holder[toolRegistrySymbol] = registry; + } + return registry; +} + +function installMcpHandlers(server: Server, registry: ServerToolRegistry): void { + if (registry.handlersInstalled) { + return; + } + server.assertCanSetRequestHandler("tools/list"); + server.assertCanSetRequestHandler("tools/call"); + server.setRequestHandler(ListToolsRequestSchema, async (): Promise<{ tools: Tool[] }> => ({ + tools: listRegisteredTools(registry), + })); + server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => ( + dispatchToolCall( + registry, + request, + extra as ToolRequestContext, + ) + )); + registry.handlersInstalled = true; +} + +function listRegisteredTools(registry: ServerToolRegistry): Tool[] { + return registry.tools.map(buildListTool); +} + +function buildListTool(tool: RegisteredTool): Tool { + const listed = { + name: tool.name, + inputSchema: loadSchema(tool.inputSchemaJson), + outputSchema: loadSchema(tool.outputSchemaJson), + } as Tool; + if (tool.title !== undefined) { + listed.title = tool.title; + } + if (tool.description !== undefined) { + listed.description = tool.description; + } + if (tool.annotations !== undefined) { + listed.annotations = tool.annotations; + } + if (tool.icons.length > 0) { + listed.icons = tool.icons as Tool["icons"]; + } + if (tool.execution !== undefined) { + listed.execution = tool.execution; + } + return listed; +} + +function loadSchema(rawSchemaJson: string): Record { + const parsed: unknown = JSON.parse(rawSchemaJson); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("mcpruntime: generated schema JSON must be an object"); + } + return parsed as Record; +} + +function compileSchema(rawSchemaJson: string): ValidateFunction { + return jsonSchemaValidator.compile(loadSchema(rawSchemaJson)); +} + +function assertValid( + toolName: string, + phase: "input" | "output", + validate: ValidateFunction, + value: unknown, +): void { + if (validate(value)) { + return; + } + const message = jsonSchemaValidator.errorsText(validate.errors); + if (phase === "input") { + invalidParams(toolName, message); + } + throw new Error(`mcpruntime: validate output for tool '${toolName}': ${message}`); +} + +function invalidParams(toolName: string, error: unknown): never { + throw new McpError( + ErrorCode.InvalidParams, + `invalid arguments for tool '${toolName}': ${errorMessage(error)}`, + ); +} + +function toolErrorResult(error: unknown): CallToolResult { + return { + content: [{ type: "text", text: errorMessage(error) }], + isError: true, + }; +} + +async function dispatchToolCall( + registry: ServerToolRegistry, + request: CallToolRequest, + extra: ToolRequestContext, +): Promise { + const toolName = request.params.name; + const tool = registry.tools.find((candidate) => candidate.name === toolName); + if (tool === undefined) { + throw new McpError(ErrorCode.InvalidParams, `unknown tool '${toolName}'`); + } + const args = (request.params.arguments ?? {}) as JsonValue; + assertValid(tool.name, "input", tool.validateInput, args); + + let parsedRequest: MessageShape; + try { + parsedRequest = fromJson(tool.inputSchema, args, { registry: tool.registry }); + } catch (error) { + invalidParams(tool.name, error); + } + + let response: unknown; + try { + response = await tool.handler(extra, parsedRequest as never); + } catch (error) { + return toolErrorResult(error); + } + + let payload: JsonValue; + try { + payload = toJson(tool.outputSchema, response as MessageShape, { + alwaysEmitImplicit: true, + registry: tool.registry, + }) as JsonValue; + } catch (error) { + throw new Error(`mcpruntime: marshal output for tool '${tool.name}': ${errorMessage(error)}`); + } + const structuredContent = assertStructuredContent(tool.name, payload); + assertValid(tool.name, "output", tool.validateOutput, structuredContent); + return { + content: [{ type: "text", text: JSON.stringify(structuredContent) }], + structuredContent, + }; +} + +function assertStructuredContent(toolName: string, payload: JsonValue): Record { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error(`mcpruntime: marshal output for tool '${toolName}': generated output must be a JSON object`); + } + return payload as Record; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message !== "") { + return error.message; + } + return String(error); +} + +function buildProtoRegistry(...fileRegistries: DescFile[]): Registry { + const collected: DescFile[] = []; + const seen = new Set(); + const visit = (file: DescFile): void => { + const key = file.proto.name || file.name; + if (seen.has(key)) { + return; + } + seen.add(key); + collected.push(file); + for (const dependency of file.dependencies) { + visit(dependency); + } + }; + for (const file of fileRegistries) { + visit(file); + } + return createRegistry(...collected); +} + +function resolveToolName( + namespace: string | null | undefined, + defaultName: string, + defaultNamespace: string, +): string { + const resolvedNamespace = normalizeToolSegment( + namespace === null || namespace === undefined ? defaultNamespace : namespace, + ); + const resolvedName = normalizeToolSegment(defaultName); + if (resolvedNamespace === "") { + return resolvedName; + } + if (resolvedName === "") { + return resolvedNamespace; + } + return `${resolvedNamespace}_${resolvedName}`; +} + +function normalizeToolSegment(segment: string | null | undefined): string { + if (segment === null || segment === undefined) { + return ""; + } + return segment.trim().replace(/[.]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, ""); +} diff --git a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts new file mode 100644 index 0000000..5c94c58 --- /dev/null +++ b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts @@ -0,0 +1,218 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "import_extension=js,target=ts" +// @generated from file proto/notebook.proto (package standalone.notebook.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "../google/protobuf/timestamp_pb.js"; +import { file_google_protobuf_timestamp } from "../google/protobuf/timestamp_pb.js"; +import { file_mcp_options_v1_options } from "../mcp/options/v1/options_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file proto/notebook.proto. + */ +export const file_proto_notebook: GenFile = /*@__PURE__*/ + fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsL8ACAQogU2VydmVyLWFzc2lnbmVkIG5vdGUgaWRlbnRpZmllci4aCAoGbm90ZS0xElQKBXRpdGxlGAIgASgJQkXatyxBGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0CiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLmABaFASdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuElEKBHRhZ3MYBCADKAlCQ9q3LD8aFyoVCgwKCnR5cGVzY3JpcHQKBQoDbWNwCiFPcHRpb25hbCB0YWdzIHVzZWQgZm9yIGZpbHRlcmluZy6AAgESWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAKKk9wdGlvbmFsIGR1ZSBkYXRlIGluIElTTyA4NjAxIGRhdGUgZm9ybWF0LloEZGF0ZRoMCgoyMDI2LTA1LTMwSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCjAAgEKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEFgAWhQGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0CiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLhJ3CgRib2R5GAIgASgJQmnatyxlaNAPGkQKQlZlcmlmeSB0aGF0IGdlbmVyYXRlZCBUeXBlU2NyaXB0IE1DUCBiaW5kaW5ncyBhcmUgcGxlYXNhbnQgdG8gdXNlLgoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAESUQoEdGFncxgDIAMoCUJD2rcsPxoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3AKIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLoACARJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuWgRkYXRlGgwKCjIwMjYtMDUtMzBIAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVgpET3B0aW9uYWwgY2FzZS1pbnNlbnNpdGl2ZSB0ZXh0IHF1ZXJ5IG1hdGNoZWQgYWdhaW5zdCB0aXRsZSBhbmQgYm9keS5gARoMCgp0eXBlc2NyaXB0SACIAQESVgoEdGFncxgCIAMoCUJI2rcsRAo0T3B0aW9uYWwgdGFnczsgYSBub3RlIG11c3QgaGF2ZSBldmVyeSByZXF1ZXN0ZWQgdGFnLoACARoJKgcKBQoDbWNwElQKBWxpbWl0GAMgASgFQkDatyw8CiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uoQEAAAAAAADwP6kBAAAAAAAASUAiAjgKSAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJ8ACAQoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LjLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMSC0NyZWF0ZSBub3RlGi5DcmVhdGUgYSBub3RlIGluIHRoZSBsb2NhbCBpbi1tZW1vcnkgbm90ZWJvb2suUgQgABAAEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0EgxTZWFyY2ggbm90ZXMaHlNlYXJjaCBub3RlcyBieSB0ZXh0IGFuZCB0YWdzLlIEIAAIARKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhIPTm90ZWJvb2sgaGVhbHRoGi1WZXJpZnkgdGhhdCB0aGUgbm90ZWJvb2sgTUNQIHNlcnZlciBpcyBhbGl2ZS5SBAgBIAAaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); + +/** + * @generated from message standalone.notebook.v1.Note + */ +export type Note = Message<"standalone.notebook.v1.Note"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string title = 2; + */ + title: string; + + /** + * @generated from field: string body = 3; + */ + body: string; + + /** + * @generated from field: repeated string tags = 4; + */ + tags: string[]; + + /** + * @generated from field: optional string due_date = 5; + */ + dueDate?: string | undefined; + + /** + * @generated from field: google.protobuf.Timestamp created_at = 6; + */ + createdAt?: Timestamp | undefined; +}; + +/** + * Describes the message standalone.notebook.v1.Note. + * Use `create(NoteSchema)` to create a new message. + */ +export const NoteSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_notebook, 0); + +/** + * @generated from message standalone.notebook.v1.CreateNoteRequest + */ +export type CreateNoteRequest = Message<"standalone.notebook.v1.CreateNoteRequest"> & { + /** + * @generated from field: string title = 1; + */ + title: string; + + /** + * @generated from field: string body = 2; + */ + body: string; + + /** + * @generated from field: repeated string tags = 3; + */ + tags: string[]; + + /** + * @generated from field: optional string due_date = 4; + */ + dueDate?: string | undefined; +}; + +/** + * Describes the message standalone.notebook.v1.CreateNoteRequest. + * Use `create(CreateNoteRequestSchema)` to create a new message. + */ +export const CreateNoteRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_notebook, 1); + +/** + * @generated from message standalone.notebook.v1.CreateNoteResponse + */ +export type CreateNoteResponse = Message<"standalone.notebook.v1.CreateNoteResponse"> & { + /** + * @generated from field: standalone.notebook.v1.Note note = 1; + */ + note?: Note | undefined; +}; + +/** + * Describes the message standalone.notebook.v1.CreateNoteResponse. + * Use `create(CreateNoteResponseSchema)` to create a new message. + */ +export const CreateNoteResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_notebook, 2); + +/** + * @generated from message standalone.notebook.v1.SearchNotesRequest + */ +export type SearchNotesRequest = Message<"standalone.notebook.v1.SearchNotesRequest"> & { + /** + * @generated from field: optional string query = 1; + */ + query?: string | undefined; + + /** + * @generated from field: repeated string tags = 2; + */ + tags: string[]; + + /** + * @generated from field: optional int32 limit = 3; + */ + limit?: number | undefined; +}; + +/** + * Describes the message standalone.notebook.v1.SearchNotesRequest. + * Use `create(SearchNotesRequestSchema)` to create a new message. + */ +export const SearchNotesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_notebook, 3); + +/** + * @generated from message standalone.notebook.v1.SearchNotesResponse + */ +export type SearchNotesResponse = Message<"standalone.notebook.v1.SearchNotesResponse"> & { + /** + * @generated from field: repeated standalone.notebook.v1.Note notes = 1; + */ + notes: Note[]; +}; + +/** + * Describes the message standalone.notebook.v1.SearchNotesResponse. + * Use `create(SearchNotesResponseSchema)` to create a new message. + */ +export const SearchNotesResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_notebook, 4); + +/** + * @generated from message standalone.notebook.v1.HealthRequest + */ +export type HealthRequest = Message<"standalone.notebook.v1.HealthRequest"> & { +}; + +/** + * Describes the message standalone.notebook.v1.HealthRequest. + * Use `create(HealthRequestSchema)` to create a new message. + */ +export const HealthRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_notebook, 5); + +/** + * @generated from message standalone.notebook.v1.HealthResponse + */ +export type HealthResponse = Message<"standalone.notebook.v1.HealthResponse"> & { + /** + * @generated from field: bool ok = 1; + */ + ok: boolean; + + /** + * @generated from field: int32 note_count = 2; + */ + noteCount: number; +}; + +/** + * Describes the message standalone.notebook.v1.HealthResponse. + * Use `create(HealthResponseSchema)` to create a new message. + */ +export const HealthResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_notebook, 6); + +/** + * @generated from service standalone.notebook.v1.NotebookAPI + */ +export const NotebookAPI: GenService<{ + /** + * @generated from rpc standalone.notebook.v1.NotebookAPI.CreateNote + */ + createNote: { + methodKind: "unary"; + input: typeof CreateNoteRequestSchema; + output: typeof CreateNoteResponseSchema; + }, + /** + * @generated from rpc standalone.notebook.v1.NotebookAPI.SearchNotes + */ + searchNotes: { + methodKind: "unary"; + input: typeof SearchNotesRequestSchema; + output: typeof SearchNotesResponseSchema; + }, + /** + * @generated from rpc standalone.notebook.v1.NotebookAPI.Health + */ + health: { + methodKind: "unary"; + input: typeof HealthRequestSchema; + output: typeof HealthResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_proto_notebook, 0); + diff --git a/examples/8_typescript_standalone/tsconfig.json b/examples/8_typescript_standalone/tsconfig.json new file mode 100644 index 0000000..1653155 --- /dev/null +++ b/examples/8_typescript_standalone/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "sourceMap": true, + "rootDir": "src", + "outDir": "dist", + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} From 5e9138d66a4ddcdc3232156f27009744ba0b3d48 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 19:51:43 +0300 Subject: [PATCH 52/74] fix(11-01): include generated TypeScript WKT descriptors --- examples/8_typescript_standalone/.gitignore | 2 +- .../google/protobuf/descriptor_pb.ts | 2628 +++++++++++++++++ .../generated/google/protobuf/timestamp_pb.ts | 150 + 3 files changed, 2779 insertions(+), 1 deletion(-) create mode 100644 examples/8_typescript_standalone/src/generated/google/protobuf/descriptor_pb.ts create mode 100644 examples/8_typescript_standalone/src/generated/google/protobuf/timestamp_pb.ts diff --git a/examples/8_typescript_standalone/.gitignore b/examples/8_typescript_standalone/.gitignore index 743a7ba..64396b0 100644 --- a/examples/8_typescript_standalone/.gitignore +++ b/examples/8_typescript_standalone/.gitignore @@ -1,3 +1,3 @@ node_modules/ dist/ -google/ +/google/ diff --git a/examples/8_typescript_standalone/src/generated/google/protobuf/descriptor_pb.ts b/examples/8_typescript_standalone/src/generated/google/protobuf/descriptor_pb.ts new file mode 100644 index 0000000..2a2c034 --- /dev/null +++ b/examples/8_typescript_standalone/src/generated/google/protobuf/descriptor_pb.ts @@ -0,0 +1,2628 @@ +// Copyright 2020-2024 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Author: kenton@google.com (Kenton Varda) +// Based on original Protocol Buffers design by +// Sanjay Ghemawat, Jeff Dean, and others. +// +// The messages in this file describe the definitions found in .proto files. +// A valid .proto file can be translated directly to a FileDescriptorProto +// without any other information (e.g. without reading its imports). + +// @generated by protoc-gen-es v2.12.0 with parameter "import_extension=js,target=ts" +// @generated from file google/protobuf/descriptor.proto (package google.protobuf, syntax proto2) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file google/protobuf/descriptor.proto. + */ +export const file_google_protobuf_descriptor: GenFile = /*@__PURE__*/ + fileDesc("CiBnb29nbGUvcHJvdG9idWYvZGVzY3JpcHRvci5wcm90bxIPZ29vZ2xlLnByb3RvYnVmIkcKEUZpbGVEZXNjcmlwdG9yU2V0EjIKBGZpbGUYASADKAsyJC5nb29nbGUucHJvdG9idWYuRmlsZURlc2NyaXB0b3JQcm90byKGBAoTRmlsZURlc2NyaXB0b3JQcm90bxIMCgRuYW1lGAEgASgJEg8KB3BhY2thZ2UYAiABKAkSEgoKZGVwZW5kZW5jeRgDIAMoCRIZChFwdWJsaWNfZGVwZW5kZW5jeRgKIAMoBRIXCg93ZWFrX2RlcGVuZGVuY3kYCyADKAUSNgoMbWVzc2FnZV90eXBlGAQgAygLMiAuZ29vZ2xlLnByb3RvYnVmLkRlc2NyaXB0b3JQcm90bxI3CgllbnVtX3R5cGUYBSADKAsyJC5nb29nbGUucHJvdG9idWYuRW51bURlc2NyaXB0b3JQcm90bxI4CgdzZXJ2aWNlGAYgAygLMicuZ29vZ2xlLnByb3RvYnVmLlNlcnZpY2VEZXNjcmlwdG9yUHJvdG8SOAoJZXh0ZW5zaW9uGAcgAygLMiUuZ29vZ2xlLnByb3RvYnVmLkZpZWxkRGVzY3JpcHRvclByb3RvEi0KB29wdGlvbnMYCCABKAsyHC5nb29nbGUucHJvdG9idWYuRmlsZU9wdGlvbnMSOQoQc291cmNlX2NvZGVfaW5mbxgJIAEoCzIfLmdvb2dsZS5wcm90b2J1Zi5Tb3VyY2VDb2RlSW5mbxIOCgZzeW50YXgYDCABKAkSKQoHZWRpdGlvbhgOIAEoDjIYLmdvb2dsZS5wcm90b2J1Zi5FZGl0aW9uIqkFCg9EZXNjcmlwdG9yUHJvdG8SDAoEbmFtZRgBIAEoCRI0CgVmaWVsZBgCIAMoCzIlLmdvb2dsZS5wcm90b2J1Zi5GaWVsZERlc2NyaXB0b3JQcm90bxI4CglleHRlbnNpb24YBiADKAsyJS5nb29nbGUucHJvdG9idWYuRmllbGREZXNjcmlwdG9yUHJvdG8SNQoLbmVzdGVkX3R5cGUYAyADKAsyIC5nb29nbGUucHJvdG9idWYuRGVzY3JpcHRvclByb3RvEjcKCWVudW1fdHlwZRgEIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5FbnVtRGVzY3JpcHRvclByb3RvEkgKD2V4dGVuc2lvbl9yYW5nZRgFIAMoCzIvLmdvb2dsZS5wcm90b2J1Zi5EZXNjcmlwdG9yUHJvdG8uRXh0ZW5zaW9uUmFuZ2USOQoKb25lb2ZfZGVjbBgIIAMoCzIlLmdvb2dsZS5wcm90b2J1Zi5PbmVvZkRlc2NyaXB0b3JQcm90bxIwCgdvcHRpb25zGAcgASgLMh8uZ29vZ2xlLnByb3RvYnVmLk1lc3NhZ2VPcHRpb25zEkYKDnJlc2VydmVkX3JhbmdlGAkgAygLMi4uZ29vZ2xlLnByb3RvYnVmLkRlc2NyaXB0b3JQcm90by5SZXNlcnZlZFJhbmdlEhUKDXJlc2VydmVkX25hbWUYCiADKAkaZQoORXh0ZW5zaW9uUmFuZ2USDQoFc3RhcnQYASABKAUSCwoDZW5kGAIgASgFEjcKB29wdGlvbnMYAyABKAsyJi5nb29nbGUucHJvdG9idWYuRXh0ZW5zaW9uUmFuZ2VPcHRpb25zGisKDVJlc2VydmVkUmFuZ2USDQoFc3RhcnQYASABKAUSCwoDZW5kGAIgASgFIuUDChVFeHRlbnNpb25SYW5nZU9wdGlvbnMSQwoUdW5pbnRlcnByZXRlZF9vcHRpb24Y5wcgAygLMiQuZ29vZ2xlLnByb3RvYnVmLlVuaW50ZXJwcmV0ZWRPcHRpb24STAoLZGVjbGFyYXRpb24YAiADKAsyMi5nb29nbGUucHJvdG9idWYuRXh0ZW5zaW9uUmFuZ2VPcHRpb25zLkRlY2xhcmF0aW9uQgOIAQISLQoIZmVhdHVyZXMYMiABKAsyGy5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldBJfCgx2ZXJpZmljYXRpb24YAyABKA4yOC5nb29nbGUucHJvdG9idWYuRXh0ZW5zaW9uUmFuZ2VPcHRpb25zLlZlcmlmaWNhdGlvblN0YXRlOgpVTlZFUklGSUVEQgOIAQIaaAoLRGVjbGFyYXRpb24SDgoGbnVtYmVyGAEgASgFEhEKCWZ1bGxfbmFtZRgCIAEoCRIMCgR0eXBlGAMgASgJEhAKCHJlc2VydmVkGAUgASgIEhAKCHJlcGVhdGVkGAYgASgISgQIBBAFIjQKEVZlcmlmaWNhdGlvblN0YXRlEg8KC0RFQ0xBUkFUSU9OEAASDgoKVU5WRVJJRklFRBABKgkI6AcQgICAgAIi1QUKFEZpZWxkRGVzY3JpcHRvclByb3RvEgwKBG5hbWUYASABKAkSDgoGbnVtYmVyGAMgASgFEjoKBWxhYmVsGAQgASgOMisuZ29vZ2xlLnByb3RvYnVmLkZpZWxkRGVzY3JpcHRvclByb3RvLkxhYmVsEjgKBHR5cGUYBSABKA4yKi5nb29nbGUucHJvdG9idWYuRmllbGREZXNjcmlwdG9yUHJvdG8uVHlwZRIRCgl0eXBlX25hbWUYBiABKAkSEAoIZXh0ZW5kZWUYAiABKAkSFQoNZGVmYXVsdF92YWx1ZRgHIAEoCRITCgtvbmVvZl9pbmRleBgJIAEoBRIRCglqc29uX25hbWUYCiABKAkSLgoHb3B0aW9ucxgIIAEoCzIdLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMSFwoPcHJvdG8zX29wdGlvbmFsGBEgASgIIrYCCgRUeXBlEg8KC1RZUEVfRE9VQkxFEAESDgoKVFlQRV9GTE9BVBACEg4KClRZUEVfSU5UNjQQAxIPCgtUWVBFX1VJTlQ2NBAEEg4KClRZUEVfSU5UMzIQBRIQCgxUWVBFX0ZJWEVENjQQBhIQCgxUWVBFX0ZJWEVEMzIQBxINCglUWVBFX0JPT0wQCBIPCgtUWVBFX1NUUklORxAJEg4KClRZUEVfR1JPVVAQChIQCgxUWVBFX01FU1NBR0UQCxIOCgpUWVBFX0JZVEVTEAwSDwoLVFlQRV9VSU5UMzIQDRINCglUWVBFX0VOVU0QDhIRCg1UWVBFX1NGSVhFRDMyEA8SEQoNVFlQRV9TRklYRUQ2NBAQEg8KC1RZUEVfU0lOVDMyEBESDwoLVFlQRV9TSU5UNjQQEiJDCgVMYWJlbBISCg5MQUJFTF9PUFRJT05BTBABEhIKDkxBQkVMX1JFUEVBVEVEEAMSEgoOTEFCRUxfUkVRVUlSRUQQAiJUChRPbmVvZkRlc2NyaXB0b3JQcm90bxIMCgRuYW1lGAEgASgJEi4KB29wdGlvbnMYAiABKAsyHS5nb29nbGUucHJvdG9idWYuT25lb2ZPcHRpb25zIqQCChNFbnVtRGVzY3JpcHRvclByb3RvEgwKBG5hbWUYASABKAkSOAoFdmFsdWUYAiADKAsyKS5nb29nbGUucHJvdG9idWYuRW51bVZhbHVlRGVzY3JpcHRvclByb3RvEi0KB29wdGlvbnMYAyABKAsyHC5nb29nbGUucHJvdG9idWYuRW51bU9wdGlvbnMSTgoOcmVzZXJ2ZWRfcmFuZ2UYBCADKAsyNi5nb29nbGUucHJvdG9idWYuRW51bURlc2NyaXB0b3JQcm90by5FbnVtUmVzZXJ2ZWRSYW5nZRIVCg1yZXNlcnZlZF9uYW1lGAUgAygJGi8KEUVudW1SZXNlcnZlZFJhbmdlEg0KBXN0YXJ0GAEgASgFEgsKA2VuZBgCIAEoBSJsChhFbnVtVmFsdWVEZXNjcmlwdG9yUHJvdG8SDAoEbmFtZRgBIAEoCRIOCgZudW1iZXIYAiABKAUSMgoHb3B0aW9ucxgDIAEoCzIhLmdvb2dsZS5wcm90b2J1Zi5FbnVtVmFsdWVPcHRpb25zIpABChZTZXJ2aWNlRGVzY3JpcHRvclByb3RvEgwKBG5hbWUYASABKAkSNgoGbWV0aG9kGAIgAygLMiYuZ29vZ2xlLnByb3RvYnVmLk1ldGhvZERlc2NyaXB0b3JQcm90bxIwCgdvcHRpb25zGAMgASgLMh8uZ29vZ2xlLnByb3RvYnVmLlNlcnZpY2VPcHRpb25zIsEBChVNZXRob2REZXNjcmlwdG9yUHJvdG8SDAoEbmFtZRgBIAEoCRISCgppbnB1dF90eXBlGAIgASgJEhMKC291dHB1dF90eXBlGAMgASgJEi8KB29wdGlvbnMYBCABKAsyHi5nb29nbGUucHJvdG9idWYuTWV0aG9kT3B0aW9ucxIfChBjbGllbnRfc3RyZWFtaW5nGAUgASgIOgVmYWxzZRIfChBzZXJ2ZXJfc3RyZWFtaW5nGAYgASgIOgVmYWxzZSLLBgoLRmlsZU9wdGlvbnMSFAoMamF2YV9wYWNrYWdlGAEgASgJEhwKFGphdmFfb3V0ZXJfY2xhc3NuYW1lGAggASgJEiIKE2phdmFfbXVsdGlwbGVfZmlsZXMYCiABKAg6BWZhbHNlEikKHWphdmFfZ2VuZXJhdGVfZXF1YWxzX2FuZF9oYXNoGBQgASgIQgIYARIlChZqYXZhX3N0cmluZ19jaGVja191dGY4GBsgASgIOgVmYWxzZRJGCgxvcHRpbWl6ZV9mb3IYCSABKA4yKS5nb29nbGUucHJvdG9idWYuRmlsZU9wdGlvbnMuT3B0aW1pemVNb2RlOgVTUEVFRBISCgpnb19wYWNrYWdlGAsgASgJEiIKE2NjX2dlbmVyaWNfc2VydmljZXMYECABKAg6BWZhbHNlEiQKFWphdmFfZ2VuZXJpY19zZXJ2aWNlcxgRIAEoCDoFZmFsc2USIgoTcHlfZ2VuZXJpY19zZXJ2aWNlcxgSIAEoCDoFZmFsc2USGQoKZGVwcmVjYXRlZBgXIAEoCDoFZmFsc2USHgoQY2NfZW5hYmxlX2FyZW5hcxgfIAEoCDoEdHJ1ZRIZChFvYmpjX2NsYXNzX3ByZWZpeBgkIAEoCRIYChBjc2hhcnBfbmFtZXNwYWNlGCUgASgJEhQKDHN3aWZ0X3ByZWZpeBgnIAEoCRIYChBwaHBfY2xhc3NfcHJlZml4GCggASgJEhUKDXBocF9uYW1lc3BhY2UYKSABKAkSHgoWcGhwX21ldGFkYXRhX25hbWVzcGFjZRgsIAEoCRIUCgxydWJ5X3BhY2thZ2UYLSABKAkSLQoIZmVhdHVyZXMYMiABKAsyGy5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldBJDChR1bmludGVycHJldGVkX29wdGlvbhjnByADKAsyJC5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvbiI6CgxPcHRpbWl6ZU1vZGUSCQoFU1BFRUQQARINCglDT0RFX1NJWkUQAhIQCgxMSVRFX1JVTlRJTUUQAyoJCOgHEICAgIACSgQIKhArSgQIJhAnUhRwaHBfZ2VuZXJpY19zZXJ2aWNlcyLnAgoOTWVzc2FnZU9wdGlvbnMSJgoXbWVzc2FnZV9zZXRfd2lyZV9mb3JtYXQYASABKAg6BWZhbHNlEi4KH25vX3N0YW5kYXJkX2Rlc2NyaXB0b3JfYWNjZXNzb3IYAiABKAg6BWZhbHNlEhkKCmRlcHJlY2F0ZWQYAyABKAg6BWZhbHNlEhEKCW1hcF9lbnRyeRgHIAEoCBIyCiZkZXByZWNhdGVkX2xlZ2FjeV9qc29uX2ZpZWxkX2NvbmZsaWN0cxgLIAEoCEICGAESLQoIZmVhdHVyZXMYDCABKAsyGy5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldBJDChR1bmludGVycHJldGVkX29wdGlvbhjnByADKAsyJC5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvbioJCOgHEICAgIACSgQIBBAFSgQIBRAGSgQIBhAHSgQICBAJSgQICRAKIqMLCgxGaWVsZE9wdGlvbnMSOgoFY3R5cGUYASABKA4yIy5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zLkNUeXBlOgZTVFJJTkcSDgoGcGFja2VkGAIgASgIEj8KBmpzdHlwZRgGIAEoDjIkLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMuSlNUeXBlOglKU19OT1JNQUwSEwoEbGF6eRgFIAEoCDoFZmFsc2USHgoPdW52ZXJpZmllZF9sYXp5GA8gASgIOgVmYWxzZRIZCgpkZXByZWNhdGVkGAMgASgIOgVmYWxzZRITCgR3ZWFrGAogASgIOgVmYWxzZRIbCgxkZWJ1Z19yZWRhY3QYECABKAg6BWZhbHNlEkAKCXJldGVudGlvbhgRIAEoDjItLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMuT3B0aW9uUmV0ZW50aW9uEj8KB3RhcmdldHMYEyADKA4yLi5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zLk9wdGlvblRhcmdldFR5cGUSRgoQZWRpdGlvbl9kZWZhdWx0cxgUIAMoCzIsLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMuRWRpdGlvbkRlZmF1bHQSLQoIZmVhdHVyZXMYFSABKAsyGy5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldBJFCg9mZWF0dXJlX3N1cHBvcnQYFiABKAsyLC5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zLkZlYXR1cmVTdXBwb3J0EkMKFHVuaW50ZXJwcmV0ZWRfb3B0aW9uGOcHIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5VbmludGVycHJldGVkT3B0aW9uGkoKDkVkaXRpb25EZWZhdWx0EikKB2VkaXRpb24YAyABKA4yGC5nb29nbGUucHJvdG9idWYuRWRpdGlvbhINCgV2YWx1ZRgCIAEoCRrMAQoORmVhdHVyZVN1cHBvcnQSNAoSZWRpdGlvbl9pbnRyb2R1Y2VkGAEgASgOMhguZ29vZ2xlLnByb3RvYnVmLkVkaXRpb24SNAoSZWRpdGlvbl9kZXByZWNhdGVkGAIgASgOMhguZ29vZ2xlLnByb3RvYnVmLkVkaXRpb24SGwoTZGVwcmVjYXRpb25fd2FybmluZxgDIAEoCRIxCg9lZGl0aW9uX3JlbW92ZWQYBCABKA4yGC5nb29nbGUucHJvdG9idWYuRWRpdGlvbiIvCgVDVHlwZRIKCgZTVFJJTkcQABIICgRDT1JEEAESEAoMU1RSSU5HX1BJRUNFEAIiNQoGSlNUeXBlEg0KCUpTX05PUk1BTBAAEg0KCUpTX1NUUklORxABEg0KCUpTX05VTUJFUhACIlUKD09wdGlvblJldGVudGlvbhIVChFSRVRFTlRJT05fVU5LTk9XThAAEhUKEVJFVEVOVElPTl9SVU5USU1FEAESFAoQUkVURU5USU9OX1NPVVJDRRACIowCChBPcHRpb25UYXJnZXRUeXBlEhcKE1RBUkdFVF9UWVBFX1VOS05PV04QABIUChBUQVJHRVRfVFlQRV9GSUxFEAESHwobVEFSR0VUX1RZUEVfRVhURU5TSU9OX1JBTkdFEAISFwoTVEFSR0VUX1RZUEVfTUVTU0FHRRADEhUKEVRBUkdFVF9UWVBFX0ZJRUxEEAQSFQoRVEFSR0VUX1RZUEVfT05FT0YQBRIUChBUQVJHRVRfVFlQRV9FTlVNEAYSGgoWVEFSR0VUX1RZUEVfRU5VTV9FTlRSWRAHEhcKE1RBUkdFVF9UWVBFX1NFUlZJQ0UQCBIWChJUQVJHRVRfVFlQRV9NRVRIT0QQCSoJCOgHEICAgIACSgQIBBAFSgQIEhATIo0BCgxPbmVvZk9wdGlvbnMSLQoIZmVhdHVyZXMYASABKAsyGy5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldBJDChR1bmludGVycHJldGVkX29wdGlvbhjnByADKAsyJC5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvbioJCOgHEICAgIACIvYBCgtFbnVtT3B0aW9ucxITCgthbGxvd19hbGlhcxgCIAEoCBIZCgpkZXByZWNhdGVkGAMgASgIOgVmYWxzZRIyCiZkZXByZWNhdGVkX2xlZ2FjeV9qc29uX2ZpZWxkX2NvbmZsaWN0cxgGIAEoCEICGAESLQoIZmVhdHVyZXMYByABKAsyGy5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldBJDChR1bmludGVycHJldGVkX29wdGlvbhjnByADKAsyJC5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvbioJCOgHEICAgIACSgQIBRAGIpACChBFbnVtVmFsdWVPcHRpb25zEhkKCmRlcHJlY2F0ZWQYASABKAg6BWZhbHNlEi0KCGZlYXR1cmVzGAIgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZlYXR1cmVTZXQSGwoMZGVidWdfcmVkYWN0GAMgASgIOgVmYWxzZRJFCg9mZWF0dXJlX3N1cHBvcnQYBCABKAsyLC5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zLkZlYXR1cmVTdXBwb3J0EkMKFHVuaW50ZXJwcmV0ZWRfb3B0aW9uGOcHIAMoCzIkLmdvb2dsZS5wcm90b2J1Zi5VbmludGVycHJldGVkT3B0aW9uKgkI6AcQgICAgAIiqgEKDlNlcnZpY2VPcHRpb25zEi0KCGZlYXR1cmVzGCIgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZlYXR1cmVTZXQSGQoKZGVwcmVjYXRlZBghIAEoCDoFZmFsc2USQwoUdW5pbnRlcnByZXRlZF9vcHRpb24Y5wcgAygLMiQuZ29vZ2xlLnByb3RvYnVmLlVuaW50ZXJwcmV0ZWRPcHRpb24qCQjoBxCAgICAAiLcAgoNTWV0aG9kT3B0aW9ucxIZCgpkZXByZWNhdGVkGCEgASgIOgVmYWxzZRJfChFpZGVtcG90ZW5jeV9sZXZlbBgiIAEoDjIvLmdvb2dsZS5wcm90b2J1Zi5NZXRob2RPcHRpb25zLklkZW1wb3RlbmN5TGV2ZWw6E0lERU1QT1RFTkNZX1VOS05PV04SLQoIZmVhdHVyZXMYIyABKAsyGy5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldBJDChR1bmludGVycHJldGVkX29wdGlvbhjnByADKAsyJC5nb29nbGUucHJvdG9idWYuVW5pbnRlcnByZXRlZE9wdGlvbiJQChBJZGVtcG90ZW5jeUxldmVsEhcKE0lERU1QT1RFTkNZX1VOS05PV04QABITCg9OT19TSURFX0VGRkVDVFMQARIOCgpJREVNUE9URU5UEAIqCQjoBxCAgICAAiKeAgoTVW5pbnRlcnByZXRlZE9wdGlvbhI7CgRuYW1lGAIgAygLMi0uZ29vZ2xlLnByb3RvYnVmLlVuaW50ZXJwcmV0ZWRPcHRpb24uTmFtZVBhcnQSGAoQaWRlbnRpZmllcl92YWx1ZRgDIAEoCRIaChJwb3NpdGl2ZV9pbnRfdmFsdWUYBCABKAQSGgoSbmVnYXRpdmVfaW50X3ZhbHVlGAUgASgDEhQKDGRvdWJsZV92YWx1ZRgGIAEoARIUCgxzdHJpbmdfdmFsdWUYByABKAwSFwoPYWdncmVnYXRlX3ZhbHVlGAggASgJGjMKCE5hbWVQYXJ0EhEKCW5hbWVfcGFydBgBIAIoCRIUCgxpc19leHRlbnNpb24YAiACKAgizwoKCkZlYXR1cmVTZXQSggEKDmZpZWxkX3ByZXNlbmNlGAEgASgOMikuZ29vZ2xlLnByb3RvYnVmLkZlYXR1cmVTZXQuRmllbGRQcmVzZW5jZUI/iAEBmAEEmAEBogENEghFWFBMSUNJVBjmB6IBDRIISU1QTElDSVQY5weiAQ0SCEVYUExJQ0lUGOgHsgEDCOgHEmIKCWVudW1fdHlwZRgCIAEoDjIkLmdvb2dsZS5wcm90b2J1Zi5GZWF0dXJlU2V0LkVudW1UeXBlQimIAQGYAQaYAQGiAQsSBkNMT1NFRBjmB6IBCRIET1BFThjnB7IBAwjoBxKBAQoXcmVwZWF0ZWRfZmllbGRfZW5jb2RpbmcYAyABKA4yMS5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldC5SZXBlYXRlZEZpZWxkRW5jb2RpbmdCLYgBAZgBBJgBAaIBDRIIRVhQQU5ERUQY5geiAQsSBlBBQ0tFRBjnB7IBAwjoBxJuCg91dGY4X3ZhbGlkYXRpb24YBCABKA4yKi5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldC5VdGY4VmFsaWRhdGlvbkIpiAEBmAEEmAEBogEJEgROT05FGOYHogELEgZWRVJJRlkY5weyAQMI6AcSbQoQbWVzc2FnZV9lbmNvZGluZxgFIAEoDjIrLmdvb2dsZS5wcm90b2J1Zi5GZWF0dXJlU2V0Lk1lc3NhZ2VFbmNvZGluZ0ImiAEBmAEEmAEBogEUEg9MRU5HVEhfUFJFRklYRUQY5geyAQMI6AcSdgoLanNvbl9mb3JtYXQYBiABKA4yJi5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldC5Kc29uRm9ybWF0QjmIAQGYAQOYAQaYAQGiARcSEkxFR0FDWV9CRVNUX0VGRk9SVBjmB6IBChIFQUxMT1cY5weyAQMI6AciXAoNRmllbGRQcmVzZW5jZRIaChZGSUVMRF9QUkVTRU5DRV9VTktOT1dOEAASDAoIRVhQTElDSVQQARIMCghJTVBMSUNJVBACEhMKD0xFR0FDWV9SRVFVSVJFRBADIjcKCEVudW1UeXBlEhUKEUVOVU1fVFlQRV9VTktOT1dOEAASCAoET1BFThABEgoKBkNMT1NFRBACIlYKFVJlcGVhdGVkRmllbGRFbmNvZGluZxIjCh9SRVBFQVRFRF9GSUVMRF9FTkNPRElOR19VTktOT1dOEAASCgoGUEFDS0VEEAESDAoIRVhQQU5ERUQQAiJJCg5VdGY4VmFsaWRhdGlvbhIbChdVVEY4X1ZBTElEQVRJT05fVU5LTk9XThAAEgoKBlZFUklGWRACEggKBE5PTkUQAyIECAEQASJTCg9NZXNzYWdlRW5jb2RpbmcSHAoYTUVTU0FHRV9FTkNPRElOR19VTktOT1dOEAASEwoPTEVOR1RIX1BSRUZJWEVEEAESDQoJREVMSU1JVEVEEAIiSAoKSnNvbkZvcm1hdBIXChNKU09OX0ZPUk1BVF9VTktOT1dOEAASCQoFQUxMT1cQARIWChJMRUdBQ1lfQkVTVF9FRkZPUlQQAiqLAQjoBxCLThqCARIdCOgHEgcucGIuY3BwGg8ucGIuQ3BwRmVhdHVyZXMSHwjpBxIILnBiLmphdmEaEC5wYi5KYXZhRmVhdHVyZXMSGwjqBxIGLnBiLmdvGg4ucGIuR29GZWF0dXJlcxIjCIZOEgoucGIucHJvdG8xGhIucGIuUHJvdG8xRmVhdHVyZXMqBgiLThCQTioGCJBOEJFOSgYI5wcQ6AcimAMKEkZlYXR1cmVTZXREZWZhdWx0cxJOCghkZWZhdWx0cxgBIAMoCzI8Lmdvb2dsZS5wcm90b2J1Zi5GZWF0dXJlU2V0RGVmYXVsdHMuRmVhdHVyZVNldEVkaXRpb25EZWZhdWx0EjEKD21pbmltdW1fZWRpdGlvbhgEIAEoDjIYLmdvb2dsZS5wcm90b2J1Zi5FZGl0aW9uEjEKD21heGltdW1fZWRpdGlvbhgFIAEoDjIYLmdvb2dsZS5wcm90b2J1Zi5FZGl0aW9uGssBChhGZWF0dXJlU2V0RWRpdGlvbkRlZmF1bHQSKQoHZWRpdGlvbhgDIAEoDjIYLmdvb2dsZS5wcm90b2J1Zi5FZGl0aW9uEjkKFG92ZXJyaWRhYmxlX2ZlYXR1cmVzGAQgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkZlYXR1cmVTZXQSMwoOZml4ZWRfZmVhdHVyZXMYBSABKAsyGy5nb29nbGUucHJvdG9idWYuRmVhdHVyZVNldEoECAEQAkoECAIQA1IIZmVhdHVyZXMi1QEKDlNvdXJjZUNvZGVJbmZvEjoKCGxvY2F0aW9uGAEgAygLMiguZ29vZ2xlLnByb3RvYnVmLlNvdXJjZUNvZGVJbmZvLkxvY2F0aW9uGoYBCghMb2NhdGlvbhIQCgRwYXRoGAEgAygFQgIQARIQCgRzcGFuGAIgAygFQgIQARIYChBsZWFkaW5nX2NvbW1lbnRzGAMgASgJEhkKEXRyYWlsaW5nX2NvbW1lbnRzGAQgASgJEiEKGWxlYWRpbmdfZGV0YWNoZWRfY29tbWVudHMYBiADKAkinAIKEUdlbmVyYXRlZENvZGVJbmZvEkEKCmFubm90YXRpb24YASADKAsyLS5nb29nbGUucHJvdG9idWYuR2VuZXJhdGVkQ29kZUluZm8uQW5ub3RhdGlvbhrDAQoKQW5ub3RhdGlvbhIQCgRwYXRoGAEgAygFQgIQARITCgtzb3VyY2VfZmlsZRgCIAEoCRINCgViZWdpbhgDIAEoBRILCgNlbmQYBCABKAUSSAoIc2VtYW50aWMYBSABKA4yNi5nb29nbGUucHJvdG9idWYuR2VuZXJhdGVkQ29kZUluZm8uQW5ub3RhdGlvbi5TZW1hbnRpYyIoCghTZW1hbnRpYxIICgROT05FEAASBwoDU0VUEAESCQoFQUxJQVMQAiqnAgoHRWRpdGlvbhITCg9FRElUSU9OX1VOS05PV04QABITCg5FRElUSU9OX0xFR0FDWRCEBxITCg5FRElUSU9OX1BST1RPMhDmBxITCg5FRElUSU9OX1BST1RPMxDnBxIRCgxFRElUSU9OXzIwMjMQ6AcSEQoMRURJVElPTl8yMDI0EOkHEhcKE0VESVRJT05fMV9URVNUX09OTFkQARIXChNFRElUSU9OXzJfVEVTVF9PTkxZEAISHQoXRURJVElPTl85OTk5N19URVNUX09OTFkQnY0GEh0KF0VESVRJT05fOTk5OThfVEVTVF9PTkxZEJ6NBhIdChdFRElUSU9OXzk5OTk5X1RFU1RfT05MWRCfjQYSEwoLRURJVElPTl9NQVgQ/////wdCfgoTY29tLmdvb2dsZS5wcm90b2J1ZkIQRGVzY3JpcHRvclByb3Rvc0gBWi1nb29nbGUuZ29sYW5nLm9yZy9wcm90b2J1Zi90eXBlcy9kZXNjcmlwdG9ycGL4AQGiAgNHUEKqAhpHb29nbGUuUHJvdG9idWYuUmVmbGVjdGlvbg"); + +/** + * The protocol compiler can output a FileDescriptorSet containing the .proto + * files it parses. + * + * @generated from message google.protobuf.FileDescriptorSet + */ +export type FileDescriptorSet = Message<"google.protobuf.FileDescriptorSet"> & { + /** + * @generated from field: repeated google.protobuf.FileDescriptorProto file = 1; + */ + file: FileDescriptorProto[]; +}; + +/** + * Describes the message google.protobuf.FileDescriptorSet. + * Use `create(FileDescriptorSetSchema)` to create a new message. + */ +export const FileDescriptorSetSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 0); + +/** + * Describes a complete .proto file. + * + * @generated from message google.protobuf.FileDescriptorProto + */ +export type FileDescriptorProto = Message<"google.protobuf.FileDescriptorProto"> & { + /** + * file name, relative to root of source tree + * + * @generated from field: optional string name = 1; + */ + name: string; + + /** + * e.g. "foo", "foo.bar", etc. + * + * @generated from field: optional string package = 2; + */ + package: string; + + /** + * Names of files imported by this file. + * + * @generated from field: repeated string dependency = 3; + */ + dependency: string[]; + + /** + * Indexes of the public imported files in the dependency list above. + * + * @generated from field: repeated int32 public_dependency = 10; + */ + publicDependency: number[]; + + /** + * Indexes of the weak imported files in the dependency list. + * For Google-internal migration only. Do not use. + * + * @generated from field: repeated int32 weak_dependency = 11; + */ + weakDependency: number[]; + + /** + * All top-level definitions in this file. + * + * @generated from field: repeated google.protobuf.DescriptorProto message_type = 4; + */ + messageType: DescriptorProto[]; + + /** + * @generated from field: repeated google.protobuf.EnumDescriptorProto enum_type = 5; + */ + enumType: EnumDescriptorProto[]; + + /** + * @generated from field: repeated google.protobuf.ServiceDescriptorProto service = 6; + */ + service: ServiceDescriptorProto[]; + + /** + * @generated from field: repeated google.protobuf.FieldDescriptorProto extension = 7; + */ + extension: FieldDescriptorProto[]; + + /** + * @generated from field: optional google.protobuf.FileOptions options = 8; + */ + options?: FileOptions | undefined; + + /** + * This field contains optional information about the original source code. + * You may safely remove this entire field without harming runtime + * functionality of the descriptors -- the information is needed only by + * development tools. + * + * @generated from field: optional google.protobuf.SourceCodeInfo source_code_info = 9; + */ + sourceCodeInfo?: SourceCodeInfo | undefined; + + /** + * The syntax of the proto file. + * The supported values are "proto2", "proto3", and "editions". + * + * If `edition` is present, this value must be "editions". + * + * @generated from field: optional string syntax = 12; + */ + syntax: string; + + /** + * The edition of the proto file. + * + * @generated from field: optional google.protobuf.Edition edition = 14; + */ + edition: Edition; +}; + +/** + * Describes the message google.protobuf.FileDescriptorProto. + * Use `create(FileDescriptorProtoSchema)` to create a new message. + */ +export const FileDescriptorProtoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 1); + +/** + * Describes a message type. + * + * @generated from message google.protobuf.DescriptorProto + */ +export type DescriptorProto = Message<"google.protobuf.DescriptorProto"> & { + /** + * @generated from field: optional string name = 1; + */ + name: string; + + /** + * @generated from field: repeated google.protobuf.FieldDescriptorProto field = 2; + */ + field: FieldDescriptorProto[]; + + /** + * @generated from field: repeated google.protobuf.FieldDescriptorProto extension = 6; + */ + extension: FieldDescriptorProto[]; + + /** + * @generated from field: repeated google.protobuf.DescriptorProto nested_type = 3; + */ + nestedType: DescriptorProto[]; + + /** + * @generated from field: repeated google.protobuf.EnumDescriptorProto enum_type = 4; + */ + enumType: EnumDescriptorProto[]; + + /** + * @generated from field: repeated google.protobuf.DescriptorProto.ExtensionRange extension_range = 5; + */ + extensionRange: DescriptorProto_ExtensionRange[]; + + /** + * @generated from field: repeated google.protobuf.OneofDescriptorProto oneof_decl = 8; + */ + oneofDecl: OneofDescriptorProto[]; + + /** + * @generated from field: optional google.protobuf.MessageOptions options = 7; + */ + options?: MessageOptions | undefined; + + /** + * @generated from field: repeated google.protobuf.DescriptorProto.ReservedRange reserved_range = 9; + */ + reservedRange: DescriptorProto_ReservedRange[]; + + /** + * Reserved field names, which may not be used by fields in the same message. + * A given name may only be reserved once. + * + * @generated from field: repeated string reserved_name = 10; + */ + reservedName: string[]; +}; + +/** + * Describes the message google.protobuf.DescriptorProto. + * Use `create(DescriptorProtoSchema)` to create a new message. + */ +export const DescriptorProtoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 2); + +/** + * @generated from message google.protobuf.DescriptorProto.ExtensionRange + */ +export type DescriptorProto_ExtensionRange = Message<"google.protobuf.DescriptorProto.ExtensionRange"> & { + /** + * Inclusive. + * + * @generated from field: optional int32 start = 1; + */ + start: number; + + /** + * Exclusive. + * + * @generated from field: optional int32 end = 2; + */ + end: number; + + /** + * @generated from field: optional google.protobuf.ExtensionRangeOptions options = 3; + */ + options?: ExtensionRangeOptions | undefined; +}; + +/** + * Describes the message google.protobuf.DescriptorProto.ExtensionRange. + * Use `create(DescriptorProto_ExtensionRangeSchema)` to create a new message. + */ +export const DescriptorProto_ExtensionRangeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 2, 0); + +/** + * Range of reserved tag numbers. Reserved tag numbers may not be used by + * fields or extension ranges in the same message. Reserved ranges may + * not overlap. + * + * @generated from message google.protobuf.DescriptorProto.ReservedRange + */ +export type DescriptorProto_ReservedRange = Message<"google.protobuf.DescriptorProto.ReservedRange"> & { + /** + * Inclusive. + * + * @generated from field: optional int32 start = 1; + */ + start: number; + + /** + * Exclusive. + * + * @generated from field: optional int32 end = 2; + */ + end: number; +}; + +/** + * Describes the message google.protobuf.DescriptorProto.ReservedRange. + * Use `create(DescriptorProto_ReservedRangeSchema)` to create a new message. + */ +export const DescriptorProto_ReservedRangeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 2, 1); + +/** + * @generated from message google.protobuf.ExtensionRangeOptions + */ +export type ExtensionRangeOptions = Message<"google.protobuf.ExtensionRangeOptions"> & { + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; + + /** + * For external users: DO NOT USE. We are in the process of open sourcing + * extension declaration and executing internal cleanups before it can be + * used externally. + * + * @generated from field: repeated google.protobuf.ExtensionRangeOptions.Declaration declaration = 2; + */ + declaration: ExtensionRangeOptions_Declaration[]; + + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 50; + */ + features?: FeatureSet | undefined; + + /** + * The verification state of the range. + * TODO: flip the default to DECLARATION once all empty ranges + * are marked as UNVERIFIED. + * + * @generated from field: optional google.protobuf.ExtensionRangeOptions.VerificationState verification = 3 [default = UNVERIFIED]; + */ + verification: ExtensionRangeOptions_VerificationState; +}; + +/** + * Describes the message google.protobuf.ExtensionRangeOptions. + * Use `create(ExtensionRangeOptionsSchema)` to create a new message. + */ +export const ExtensionRangeOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 3); + +/** + * @generated from message google.protobuf.ExtensionRangeOptions.Declaration + */ +export type ExtensionRangeOptions_Declaration = Message<"google.protobuf.ExtensionRangeOptions.Declaration"> & { + /** + * The extension number declared within the extension range. + * + * @generated from field: optional int32 number = 1; + */ + number: number; + + /** + * The fully-qualified name of the extension field. There must be a leading + * dot in front of the full name. + * + * @generated from field: optional string full_name = 2; + */ + fullName: string; + + /** + * The fully-qualified type name of the extension field. Unlike + * Metadata.type, Declaration.type must have a leading dot for messages + * and enums. + * + * @generated from field: optional string type = 3; + */ + type: string; + + /** + * If true, indicates that the number is reserved in the extension range, + * and any extension field with the number will fail to compile. Set this + * when a declared extension field is deleted. + * + * @generated from field: optional bool reserved = 5; + */ + reserved: boolean; + + /** + * If true, indicates that the extension must be defined as repeated. + * Otherwise the extension must be defined as optional. + * + * @generated from field: optional bool repeated = 6; + */ + repeated: boolean; +}; + +/** + * Describes the message google.protobuf.ExtensionRangeOptions.Declaration. + * Use `create(ExtensionRangeOptions_DeclarationSchema)` to create a new message. + */ +export const ExtensionRangeOptions_DeclarationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 3, 0); + +/** + * The verification state of the extension range. + * + * @generated from enum google.protobuf.ExtensionRangeOptions.VerificationState + */ +export enum ExtensionRangeOptions_VerificationState { + /** + * All the extensions of the range must be declared. + * + * @generated from enum value: DECLARATION = 0; + */ + DECLARATION = 0, + + /** + * @generated from enum value: UNVERIFIED = 1; + */ + UNVERIFIED = 1, +} + +/** + * Describes the enum google.protobuf.ExtensionRangeOptions.VerificationState. + */ +export const ExtensionRangeOptions_VerificationStateSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 3, 0); + +/** + * Describes a field within a message. + * + * @generated from message google.protobuf.FieldDescriptorProto + */ +export type FieldDescriptorProto = Message<"google.protobuf.FieldDescriptorProto"> & { + /** + * @generated from field: optional string name = 1; + */ + name: string; + + /** + * @generated from field: optional int32 number = 3; + */ + number: number; + + /** + * @generated from field: optional google.protobuf.FieldDescriptorProto.Label label = 4; + */ + label: FieldDescriptorProto_Label; + + /** + * If type_name is set, this need not be set. If both this and type_name + * are set, this must be one of TYPE_ENUM, TYPE_MESSAGE or TYPE_GROUP. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type type = 5; + */ + type: FieldDescriptorProto_Type; + + /** + * For message and enum types, this is the name of the type. If the name + * starts with a '.', it is fully-qualified. Otherwise, C++-like scoping + * rules are used to find the type (i.e. first the nested types within this + * message are searched, then within the parent, on up to the root + * namespace). + * + * @generated from field: optional string type_name = 6; + */ + typeName: string; + + /** + * For extensions, this is the name of the type being extended. It is + * resolved in the same manner as type_name. + * + * @generated from field: optional string extendee = 2; + */ + extendee: string; + + /** + * For numeric types, contains the original text representation of the value. + * For booleans, "true" or "false". + * For strings, contains the default text contents (not escaped in any way). + * For bytes, contains the C escaped value. All bytes >= 128 are escaped. + * + * @generated from field: optional string default_value = 7; + */ + defaultValue: string; + + /** + * If set, gives the index of a oneof in the containing type's oneof_decl + * list. This field is a member of that oneof. + * + * @generated from field: optional int32 oneof_index = 9; + */ + oneofIndex: number; + + /** + * JSON name of this field. The value is set by protocol compiler. If the + * user has set a "json_name" option on this field, that option's value + * will be used. Otherwise, it's deduced from the field's name by converting + * it to camelCase. + * + * @generated from field: optional string json_name = 10; + */ + jsonName: string; + + /** + * @generated from field: optional google.protobuf.FieldOptions options = 8; + */ + options?: FieldOptions | undefined; + + /** + * If true, this is a proto3 "optional". When a proto3 field is optional, it + * tracks presence regardless of field type. + * + * When proto3_optional is true, this field must belong to a oneof to signal + * to old proto3 clients that presence is tracked for this field. This oneof + * is known as a "synthetic" oneof, and this field must be its sole member + * (each proto3 optional field gets its own synthetic oneof). Synthetic oneofs + * exist in the descriptor only, and do not generate any API. Synthetic oneofs + * must be ordered after all "real" oneofs. + * + * For message fields, proto3_optional doesn't create any semantic change, + * since non-repeated message fields always track presence. However it still + * indicates the semantic detail of whether the user wrote "optional" or not. + * This can be useful for round-tripping the .proto file. For consistency we + * give message fields a synthetic oneof also, even though it is not required + * to track presence. This is especially important because the parser can't + * tell if a field is a message or an enum, so it must always create a + * synthetic oneof. + * + * Proto2 optional fields do not set this flag, because they already indicate + * optional with `LABEL_OPTIONAL`. + * + * @generated from field: optional bool proto3_optional = 17; + */ + proto3Optional: boolean; +}; + +/** + * Describes the message google.protobuf.FieldDescriptorProto. + * Use `create(FieldDescriptorProtoSchema)` to create a new message. + */ +export const FieldDescriptorProtoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 4); + +/** + * @generated from enum google.protobuf.FieldDescriptorProto.Type + */ +export enum FieldDescriptorProto_Type { + /** + * 0 is reserved for errors. + * Order is weird for historical reasons. + * + * @generated from enum value: TYPE_DOUBLE = 1; + */ + DOUBLE = 1, + + /** + * @generated from enum value: TYPE_FLOAT = 2; + */ + FLOAT = 2, + + /** + * Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT64 if + * negative values are likely. + * + * @generated from enum value: TYPE_INT64 = 3; + */ + INT64 = 3, + + /** + * @generated from enum value: TYPE_UINT64 = 4; + */ + UINT64 = 4, + + /** + * Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT32 if + * negative values are likely. + * + * @generated from enum value: TYPE_INT32 = 5; + */ + INT32 = 5, + + /** + * @generated from enum value: TYPE_FIXED64 = 6; + */ + FIXED64 = 6, + + /** + * @generated from enum value: TYPE_FIXED32 = 7; + */ + FIXED32 = 7, + + /** + * @generated from enum value: TYPE_BOOL = 8; + */ + BOOL = 8, + + /** + * @generated from enum value: TYPE_STRING = 9; + */ + STRING = 9, + + /** + * Tag-delimited aggregate. + * Group type is deprecated and not supported after google.protobuf. However, Proto3 + * implementations should still be able to parse the group wire format and + * treat group fields as unknown fields. In Editions, the group wire format + * can be enabled via the `message_encoding` feature. + * + * @generated from enum value: TYPE_GROUP = 10; + */ + GROUP = 10, + + /** + * Length-delimited aggregate. + * + * @generated from enum value: TYPE_MESSAGE = 11; + */ + MESSAGE = 11, + + /** + * New in version 2. + * + * @generated from enum value: TYPE_BYTES = 12; + */ + BYTES = 12, + + /** + * @generated from enum value: TYPE_UINT32 = 13; + */ + UINT32 = 13, + + /** + * @generated from enum value: TYPE_ENUM = 14; + */ + ENUM = 14, + + /** + * @generated from enum value: TYPE_SFIXED32 = 15; + */ + SFIXED32 = 15, + + /** + * @generated from enum value: TYPE_SFIXED64 = 16; + */ + SFIXED64 = 16, + + /** + * Uses ZigZag encoding. + * + * @generated from enum value: TYPE_SINT32 = 17; + */ + SINT32 = 17, + + /** + * Uses ZigZag encoding. + * + * @generated from enum value: TYPE_SINT64 = 18; + */ + SINT64 = 18, +} + +/** + * Describes the enum google.protobuf.FieldDescriptorProto.Type. + */ +export const FieldDescriptorProto_TypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 4, 0); + +/** + * @generated from enum google.protobuf.FieldDescriptorProto.Label + */ +export enum FieldDescriptorProto_Label { + /** + * 0 is reserved for errors + * + * @generated from enum value: LABEL_OPTIONAL = 1; + */ + OPTIONAL = 1, + + /** + * @generated from enum value: LABEL_REPEATED = 3; + */ + REPEATED = 3, + + /** + * The required label is only allowed in google.protobuf. In proto3 and Editions + * it's explicitly prohibited. In Editions, the `field_presence` feature + * can be used to get this behavior. + * + * @generated from enum value: LABEL_REQUIRED = 2; + */ + REQUIRED = 2, +} + +/** + * Describes the enum google.protobuf.FieldDescriptorProto.Label. + */ +export const FieldDescriptorProto_LabelSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 4, 1); + +/** + * Describes a oneof. + * + * @generated from message google.protobuf.OneofDescriptorProto + */ +export type OneofDescriptorProto = Message<"google.protobuf.OneofDescriptorProto"> & { + /** + * @generated from field: optional string name = 1; + */ + name: string; + + /** + * @generated from field: optional google.protobuf.OneofOptions options = 2; + */ + options?: OneofOptions | undefined; +}; + +/** + * Describes the message google.protobuf.OneofDescriptorProto. + * Use `create(OneofDescriptorProtoSchema)` to create a new message. + */ +export const OneofDescriptorProtoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 5); + +/** + * Describes an enum type. + * + * @generated from message google.protobuf.EnumDescriptorProto + */ +export type EnumDescriptorProto = Message<"google.protobuf.EnumDescriptorProto"> & { + /** + * @generated from field: optional string name = 1; + */ + name: string; + + /** + * @generated from field: repeated google.protobuf.EnumValueDescriptorProto value = 2; + */ + value: EnumValueDescriptorProto[]; + + /** + * @generated from field: optional google.protobuf.EnumOptions options = 3; + */ + options?: EnumOptions | undefined; + + /** + * Range of reserved numeric values. Reserved numeric values may not be used + * by enum values in the same enum declaration. Reserved ranges may not + * overlap. + * + * @generated from field: repeated google.protobuf.EnumDescriptorProto.EnumReservedRange reserved_range = 4; + */ + reservedRange: EnumDescriptorProto_EnumReservedRange[]; + + /** + * Reserved enum value names, which may not be reused. A given name may only + * be reserved once. + * + * @generated from field: repeated string reserved_name = 5; + */ + reservedName: string[]; +}; + +/** + * Describes the message google.protobuf.EnumDescriptorProto. + * Use `create(EnumDescriptorProtoSchema)` to create a new message. + */ +export const EnumDescriptorProtoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 6); + +/** + * Range of reserved numeric values. Reserved values may not be used by + * entries in the same enum. Reserved ranges may not overlap. + * + * Note that this is distinct from DescriptorProto.ReservedRange in that it + * is inclusive such that it can appropriately represent the entire int32 + * domain. + * + * @generated from message google.protobuf.EnumDescriptorProto.EnumReservedRange + */ +export type EnumDescriptorProto_EnumReservedRange = Message<"google.protobuf.EnumDescriptorProto.EnumReservedRange"> & { + /** + * Inclusive. + * + * @generated from field: optional int32 start = 1; + */ + start: number; + + /** + * Inclusive. + * + * @generated from field: optional int32 end = 2; + */ + end: number; +}; + +/** + * Describes the message google.protobuf.EnumDescriptorProto.EnumReservedRange. + * Use `create(EnumDescriptorProto_EnumReservedRangeSchema)` to create a new message. + */ +export const EnumDescriptorProto_EnumReservedRangeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 6, 0); + +/** + * Describes a value within an enum. + * + * @generated from message google.protobuf.EnumValueDescriptorProto + */ +export type EnumValueDescriptorProto = Message<"google.protobuf.EnumValueDescriptorProto"> & { + /** + * @generated from field: optional string name = 1; + */ + name: string; + + /** + * @generated from field: optional int32 number = 2; + */ + number: number; + + /** + * @generated from field: optional google.protobuf.EnumValueOptions options = 3; + */ + options?: EnumValueOptions | undefined; +}; + +/** + * Describes the message google.protobuf.EnumValueDescriptorProto. + * Use `create(EnumValueDescriptorProtoSchema)` to create a new message. + */ +export const EnumValueDescriptorProtoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 7); + +/** + * Describes a service. + * + * @generated from message google.protobuf.ServiceDescriptorProto + */ +export type ServiceDescriptorProto = Message<"google.protobuf.ServiceDescriptorProto"> & { + /** + * @generated from field: optional string name = 1; + */ + name: string; + + /** + * @generated from field: repeated google.protobuf.MethodDescriptorProto method = 2; + */ + method: MethodDescriptorProto[]; + + /** + * @generated from field: optional google.protobuf.ServiceOptions options = 3; + */ + options?: ServiceOptions | undefined; +}; + +/** + * Describes the message google.protobuf.ServiceDescriptorProto. + * Use `create(ServiceDescriptorProtoSchema)` to create a new message. + */ +export const ServiceDescriptorProtoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 8); + +/** + * Describes a method of a service. + * + * @generated from message google.protobuf.MethodDescriptorProto + */ +export type MethodDescriptorProto = Message<"google.protobuf.MethodDescriptorProto"> & { + /** + * @generated from field: optional string name = 1; + */ + name: string; + + /** + * Input and output type names. These are resolved in the same way as + * FieldDescriptorProto.type_name, but must refer to a message type. + * + * @generated from field: optional string input_type = 2; + */ + inputType: string; + + /** + * @generated from field: optional string output_type = 3; + */ + outputType: string; + + /** + * @generated from field: optional google.protobuf.MethodOptions options = 4; + */ + options?: MethodOptions | undefined; + + /** + * Identifies if client streams multiple client messages + * + * @generated from field: optional bool client_streaming = 5 [default = false]; + */ + clientStreaming: boolean; + + /** + * Identifies if server streams multiple server messages + * + * @generated from field: optional bool server_streaming = 6 [default = false]; + */ + serverStreaming: boolean; +}; + +/** + * Describes the message google.protobuf.MethodDescriptorProto. + * Use `create(MethodDescriptorProtoSchema)` to create a new message. + */ +export const MethodDescriptorProtoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 9); + +/** + * @generated from message google.protobuf.FileOptions + */ +export type FileOptions = Message<"google.protobuf.FileOptions"> & { + /** + * Sets the Java package where classes generated from this .proto will be + * placed. By default, the proto package is used, but this is often + * inappropriate because proto packages do not normally start with backwards + * domain names. + * + * @generated from field: optional string java_package = 1; + */ + javaPackage: string; + + /** + * Controls the name of the wrapper Java class generated for the .proto file. + * That class will always contain the .proto file's getDescriptor() method as + * well as any top-level extensions defined in the .proto file. + * If java_multiple_files is disabled, then all the other classes from the + * .proto file will be nested inside the single wrapper outer class. + * + * @generated from field: optional string java_outer_classname = 8; + */ + javaOuterClassname: string; + + /** + * If enabled, then the Java code generator will generate a separate .java + * file for each top-level message, enum, and service defined in the .proto + * file. Thus, these types will *not* be nested inside the wrapper class + * named by java_outer_classname. However, the wrapper class will still be + * generated to contain the file's getDescriptor() method as well as any + * top-level extensions defined in the file. + * + * @generated from field: optional bool java_multiple_files = 10 [default = false]; + */ + javaMultipleFiles: boolean; + + /** + * This option does nothing. + * + * @generated from field: optional bool java_generate_equals_and_hash = 20 [deprecated = true]; + * @deprecated + */ + javaGenerateEqualsAndHash: boolean; + + /** + * A proto2 file can set this to true to opt in to UTF-8 checking for Java, + * which will throw an exception if invalid UTF-8 is parsed from the wire or + * assigned to a string field. + * + * TODO: clarify exactly what kinds of field types this option + * applies to, and update these docs accordingly. + * + * Proto3 files already perform these checks. Setting the option explicitly to + * false has no effect: it cannot be used to opt proto3 files out of UTF-8 + * checks. + * + * @generated from field: optional bool java_string_check_utf8 = 27 [default = false]; + */ + javaStringCheckUtf8: boolean; + + /** + * @generated from field: optional google.protobuf.FileOptions.OptimizeMode optimize_for = 9 [default = SPEED]; + */ + optimizeFor: FileOptions_OptimizeMode; + + /** + * Sets the Go package where structs generated from this .proto will be + * placed. If omitted, the Go package will be derived from the following: + * - The basename of the package import path, if provided. + * - Otherwise, the package statement in the .proto file, if present. + * - Otherwise, the basename of the .proto file, without extension. + * + * @generated from field: optional string go_package = 11; + */ + goPackage: string; + + /** + * Should generic services be generated in each language? "Generic" services + * are not specific to any particular RPC system. They are generated by the + * main code generators in each language (without additional plugins). + * Generic services were the only kind of service generation supported by + * early versions of google.protobuf. + * + * Generic services are now considered deprecated in favor of using plugins + * that generate code specific to your particular RPC system. Therefore, + * these default to false. Old code which depends on generic services should + * explicitly set them to true. + * + * @generated from field: optional bool cc_generic_services = 16 [default = false]; + */ + ccGenericServices: boolean; + + /** + * @generated from field: optional bool java_generic_services = 17 [default = false]; + */ + javaGenericServices: boolean; + + /** + * @generated from field: optional bool py_generic_services = 18 [default = false]; + */ + pyGenericServices: boolean; + + /** + * Is this file deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for everything in the file, or it will be completely ignored; in the very + * least, this is a formalization for deprecating files. + * + * @generated from field: optional bool deprecated = 23 [default = false]; + */ + deprecated: boolean; + + /** + * Enables the use of arenas for the proto messages in this file. This applies + * only to generated classes for C++. + * + * @generated from field: optional bool cc_enable_arenas = 31 [default = true]; + */ + ccEnableArenas: boolean; + + /** + * Sets the objective c class prefix which is prepended to all objective c + * generated classes from this .proto. There is no default. + * + * @generated from field: optional string objc_class_prefix = 36; + */ + objcClassPrefix: string; + + /** + * Namespace for generated classes; defaults to the package. + * + * @generated from field: optional string csharp_namespace = 37; + */ + csharpNamespace: string; + + /** + * By default Swift generators will take the proto package and CamelCase it + * replacing '.' with underscore and use that to prefix the types/symbols + * defined. When this options is provided, they will use this value instead + * to prefix the types/symbols defined. + * + * @generated from field: optional string swift_prefix = 39; + */ + swiftPrefix: string; + + /** + * Sets the php class prefix which is prepended to all php generated classes + * from this .proto. Default is empty. + * + * @generated from field: optional string php_class_prefix = 40; + */ + phpClassPrefix: string; + + /** + * Use this option to change the namespace of php generated classes. Default + * is empty. When this option is empty, the package name will be used for + * determining the namespace. + * + * @generated from field: optional string php_namespace = 41; + */ + phpNamespace: string; + + /** + * Use this option to change the namespace of php generated metadata classes. + * Default is empty. When this option is empty, the proto file name will be + * used for determining the namespace. + * + * @generated from field: optional string php_metadata_namespace = 44; + */ + phpMetadataNamespace: string; + + /** + * Use this option to change the package of ruby generated classes. Default + * is empty. When this option is not set, the package name will be used for + * determining the ruby package. + * + * @generated from field: optional string ruby_package = 45; + */ + rubyPackage: string; + + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 50; + */ + features?: FeatureSet | undefined; + + /** + * The parser stores options it doesn't recognize here. + * See the documentation for the "Options" section above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; +}; + +/** + * Describes the message google.protobuf.FileOptions. + * Use `create(FileOptionsSchema)` to create a new message. + */ +export const FileOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 10); + +/** + * Generated classes can be optimized for speed or code size. + * + * @generated from enum google.protobuf.FileOptions.OptimizeMode + */ +export enum FileOptions_OptimizeMode { + /** + * Generate complete code for parsing, serialization, + * + * @generated from enum value: SPEED = 1; + */ + SPEED = 1, + + /** + * etc. + * + * Use ReflectionOps to implement these methods. + * + * @generated from enum value: CODE_SIZE = 2; + */ + CODE_SIZE = 2, + + /** + * Generate code using MessageLite and the lite runtime. + * + * @generated from enum value: LITE_RUNTIME = 3; + */ + LITE_RUNTIME = 3, +} + +/** + * Describes the enum google.protobuf.FileOptions.OptimizeMode. + */ +export const FileOptions_OptimizeModeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 10, 0); + +/** + * @generated from message google.protobuf.MessageOptions + */ +export type MessageOptions = Message<"google.protobuf.MessageOptions"> & { + /** + * Set true to use the old proto1 MessageSet wire format for extensions. + * This is provided for backwards-compatibility with the MessageSet wire + * format. You should not use this for any other reason: It's less + * efficient, has fewer features, and is more complicated. + * + * The message must be defined exactly as follows: + * message Foo { + * option message_set_wire_format = true; + * extensions 4 to max; + * } + * Note that the message cannot have any defined fields; MessageSets only + * have extensions. + * + * All extensions of your type must be singular messages; e.g. they cannot + * be int32s, enums, or repeated messages. + * + * Because this is an option, the above two restrictions are not enforced by + * the protocol compiler. + * + * @generated from field: optional bool message_set_wire_format = 1 [default = false]; + */ + messageSetWireFormat: boolean; + + /** + * Disables the generation of the standard "descriptor()" accessor, which can + * conflict with a field of the same name. This is meant to make migration + * from proto1 easier; new code should avoid fields named "descriptor". + * + * @generated from field: optional bool no_standard_descriptor_accessor = 2 [default = false]; + */ + noStandardDescriptorAccessor: boolean; + + /** + * Is this message deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the message, or it will be completely ignored; in the very least, + * this is a formalization for deprecating messages. + * + * @generated from field: optional bool deprecated = 3 [default = false]; + */ + deprecated: boolean; + + /** + * Whether the message is an automatically generated map entry type for the + * maps field. + * + * For maps fields: + * map map_field = 1; + * The parsed descriptor looks like: + * message MapFieldEntry { + * option map_entry = true; + * optional KeyType key = 1; + * optional ValueType value = 2; + * } + * repeated MapFieldEntry map_field = 1; + * + * Implementations may choose not to generate the map_entry=true message, but + * use a native map in the target language to hold the keys and values. + * The reflection APIs in such implementations still need to work as + * if the field is a repeated message field. + * + * NOTE: Do not set the option in .proto files. Always use the maps syntax + * instead. The option should only be implicitly set by the proto compiler + * parser. + * + * @generated from field: optional bool map_entry = 7; + */ + mapEntry: boolean; + + /** + * Enable the legacy handling of JSON field name conflicts. This lowercases + * and strips underscored from the fields before comparison in proto3 only. + * The new behavior takes `json_name` into account and applies to proto2 as + * well. + * + * This should only be used as a temporary measure against broken builds due + * to the change in behavior for JSON field name conflicts. + * + * TODO This is legacy behavior we plan to remove once downstream + * teams have had time to migrate. + * + * @generated from field: optional bool deprecated_legacy_json_field_conflicts = 11 [deprecated = true]; + * @deprecated + */ + deprecatedLegacyJsonFieldConflicts: boolean; + + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 12; + */ + features?: FeatureSet | undefined; + + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; +}; + +/** + * Describes the message google.protobuf.MessageOptions. + * Use `create(MessageOptionsSchema)` to create a new message. + */ +export const MessageOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 11); + +/** + * @generated from message google.protobuf.FieldOptions + */ +export type FieldOptions = Message<"google.protobuf.FieldOptions"> & { + /** + * The ctype option instructs the C++ code generator to use a different + * representation of the field than it normally would. See the specific + * options below. This option is only implemented to support use of + * [ctype=CORD] and [ctype=STRING] (the default) on non-repeated fields of + * type "bytes" in the open source release -- sorry, we'll try to include + * other types in a future version! + * + * @generated from field: optional google.protobuf.FieldOptions.CType ctype = 1 [default = STRING]; + */ + ctype: FieldOptions_CType; + + /** + * The packed option can be enabled for repeated primitive fields to enable + * a more efficient representation on the wire. Rather than repeatedly + * writing the tag and type for each element, the entire array is encoded as + * a single length-delimited blob. In proto3, only explicit setting it to + * false will avoid using packed encoding. This option is prohibited in + * Editions, but the `repeated_field_encoding` feature can be used to control + * the behavior. + * + * @generated from field: optional bool packed = 2; + */ + packed: boolean; + + /** + * The jstype option determines the JavaScript type used for values of the + * field. The option is permitted only for 64 bit integral and fixed types + * (int64, uint64, sint64, fixed64, sfixed64). A field with jstype JS_STRING + * is represented as JavaScript string, which avoids loss of precision that + * can happen when a large value is converted to a floating point JavaScript. + * Specifying JS_NUMBER for the jstype causes the generated JavaScript code to + * use the JavaScript "number" type. The behavior of the default option + * JS_NORMAL is implementation dependent. + * + * This option is an enum to permit additional types to be added, e.g. + * goog.math.Integer. + * + * @generated from field: optional google.protobuf.FieldOptions.JSType jstype = 6 [default = JS_NORMAL]; + */ + jstype: FieldOptions_JSType; + + /** + * Should this field be parsed lazily? Lazy applies only to message-type + * fields. It means that when the outer message is initially parsed, the + * inner message's contents will not be parsed but instead stored in encoded + * form. The inner message will actually be parsed when it is first accessed. + * + * This is only a hint. Implementations are free to choose whether to use + * eager or lazy parsing regardless of the value of this option. However, + * setting this option true suggests that the protocol author believes that + * using lazy parsing on this field is worth the additional bookkeeping + * overhead typically needed to implement it. + * + * This option does not affect the public interface of any generated code; + * all method signatures remain the same. Furthermore, thread-safety of the + * interface is not affected by this option; const methods remain safe to + * call from multiple threads concurrently, while non-const methods continue + * to require exclusive access. + * + * Note that lazy message fields are still eagerly verified to check + * ill-formed wireformat or missing required fields. Calling IsInitialized() + * on the outer message would fail if the inner message has missing required + * fields. Failed verification would result in parsing failure (except when + * uninitialized messages are acceptable). + * + * @generated from field: optional bool lazy = 5 [default = false]; + */ + lazy: boolean; + + /** + * unverified_lazy does no correctness checks on the byte stream. This should + * only be used where lazy with verification is prohibitive for performance + * reasons. + * + * @generated from field: optional bool unverified_lazy = 15 [default = false]; + */ + unverifiedLazy: boolean; + + /** + * Is this field deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for accessors, or it will be completely ignored; in the very least, this + * is a formalization for deprecating fields. + * + * @generated from field: optional bool deprecated = 3 [default = false]; + */ + deprecated: boolean; + + /** + * For Google-internal migration only. Do not use. + * + * @generated from field: optional bool weak = 10 [default = false]; + */ + weak: boolean; + + /** + * Indicate that the field value should not be printed out when using debug + * formats, e.g. when the field contains sensitive credentials. + * + * @generated from field: optional bool debug_redact = 16 [default = false]; + */ + debugRedact: boolean; + + /** + * @generated from field: optional google.protobuf.FieldOptions.OptionRetention retention = 17; + */ + retention: FieldOptions_OptionRetention; + + /** + * @generated from field: repeated google.protobuf.FieldOptions.OptionTargetType targets = 19; + */ + targets: FieldOptions_OptionTargetType[]; + + /** + * @generated from field: repeated google.protobuf.FieldOptions.EditionDefault edition_defaults = 20; + */ + editionDefaults: FieldOptions_EditionDefault[]; + + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 21; + */ + features?: FeatureSet | undefined; + + /** + * @generated from field: optional google.protobuf.FieldOptions.FeatureSupport feature_support = 22; + */ + featureSupport?: FieldOptions_FeatureSupport | undefined; + + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; +}; + +/** + * Describes the message google.protobuf.FieldOptions. + * Use `create(FieldOptionsSchema)` to create a new message. + */ +export const FieldOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 12); + +/** + * @generated from message google.protobuf.FieldOptions.EditionDefault + */ +export type FieldOptions_EditionDefault = Message<"google.protobuf.FieldOptions.EditionDefault"> & { + /** + * @generated from field: optional google.protobuf.Edition edition = 3; + */ + edition: Edition; + + /** + * Textproto value. + * + * @generated from field: optional string value = 2; + */ + value: string; +}; + +/** + * Describes the message google.protobuf.FieldOptions.EditionDefault. + * Use `create(FieldOptions_EditionDefaultSchema)` to create a new message. + */ +export const FieldOptions_EditionDefaultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 12, 0); + +/** + * Information about the support window of a feature. + * + * @generated from message google.protobuf.FieldOptions.FeatureSupport + */ +export type FieldOptions_FeatureSupport = Message<"google.protobuf.FieldOptions.FeatureSupport"> & { + /** + * The edition that this feature was first available in. In editions + * earlier than this one, the default assigned to EDITION_LEGACY will be + * used, and proto files will not be able to override it. + * + * @generated from field: optional google.protobuf.Edition edition_introduced = 1; + */ + editionIntroduced: Edition; + + /** + * The edition this feature becomes deprecated in. Using this after this + * edition may trigger warnings. + * + * @generated from field: optional google.protobuf.Edition edition_deprecated = 2; + */ + editionDeprecated: Edition; + + /** + * The deprecation warning text if this feature is used after the edition it + * was marked deprecated in. + * + * @generated from field: optional string deprecation_warning = 3; + */ + deprecationWarning: string; + + /** + * The edition this feature is no longer available in. In editions after + * this one, the last default assigned will be used, and proto files will + * not be able to override it. + * + * @generated from field: optional google.protobuf.Edition edition_removed = 4; + */ + editionRemoved: Edition; +}; + +/** + * Describes the message google.protobuf.FieldOptions.FeatureSupport. + * Use `create(FieldOptions_FeatureSupportSchema)` to create a new message. + */ +export const FieldOptions_FeatureSupportSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 12, 1); + +/** + * @generated from enum google.protobuf.FieldOptions.CType + */ +export enum FieldOptions_CType { + /** + * Default mode. + * + * @generated from enum value: STRING = 0; + */ + STRING = 0, + + /** + * The option [ctype=CORD] may be applied to a non-repeated field of type + * "bytes". It indicates that in C++, the data should be stored in a Cord + * instead of a string. For very large strings, this may reduce memory + * fragmentation. It may also allow better performance when parsing from a + * Cord, or when parsing with aliasing enabled, as the parsed Cord may then + * alias the original buffer. + * + * @generated from enum value: CORD = 1; + */ + CORD = 1, + + /** + * @generated from enum value: STRING_PIECE = 2; + */ + STRING_PIECE = 2, +} + +/** + * Describes the enum google.protobuf.FieldOptions.CType. + */ +export const FieldOptions_CTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 12, 0); + +/** + * @generated from enum google.protobuf.FieldOptions.JSType + */ +export enum FieldOptions_JSType { + /** + * Use the default type. + * + * @generated from enum value: JS_NORMAL = 0; + */ + JS_NORMAL = 0, + + /** + * Use JavaScript strings. + * + * @generated from enum value: JS_STRING = 1; + */ + JS_STRING = 1, + + /** + * Use JavaScript numbers. + * + * @generated from enum value: JS_NUMBER = 2; + */ + JS_NUMBER = 2, +} + +/** + * Describes the enum google.protobuf.FieldOptions.JSType. + */ +export const FieldOptions_JSTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 12, 1); + +/** + * If set to RETENTION_SOURCE, the option will be omitted from the binary. + * Note: as of January 2023, support for this is in progress and does not yet + * have an effect (b/264593489). + * + * @generated from enum google.protobuf.FieldOptions.OptionRetention + */ +export enum FieldOptions_OptionRetention { + /** + * @generated from enum value: RETENTION_UNKNOWN = 0; + */ + RETENTION_UNKNOWN = 0, + + /** + * @generated from enum value: RETENTION_RUNTIME = 1; + */ + RETENTION_RUNTIME = 1, + + /** + * @generated from enum value: RETENTION_SOURCE = 2; + */ + RETENTION_SOURCE = 2, +} + +/** + * Describes the enum google.protobuf.FieldOptions.OptionRetention. + */ +export const FieldOptions_OptionRetentionSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 12, 2); + +/** + * This indicates the types of entities that the field may apply to when used + * as an option. If it is unset, then the field may be freely used as an + * option on any kind of entity. Note: as of January 2023, support for this is + * in progress and does not yet have an effect (b/264593489). + * + * @generated from enum google.protobuf.FieldOptions.OptionTargetType + */ +export enum FieldOptions_OptionTargetType { + /** + * @generated from enum value: TARGET_TYPE_UNKNOWN = 0; + */ + TARGET_TYPE_UNKNOWN = 0, + + /** + * @generated from enum value: TARGET_TYPE_FILE = 1; + */ + TARGET_TYPE_FILE = 1, + + /** + * @generated from enum value: TARGET_TYPE_EXTENSION_RANGE = 2; + */ + TARGET_TYPE_EXTENSION_RANGE = 2, + + /** + * @generated from enum value: TARGET_TYPE_MESSAGE = 3; + */ + TARGET_TYPE_MESSAGE = 3, + + /** + * @generated from enum value: TARGET_TYPE_FIELD = 4; + */ + TARGET_TYPE_FIELD = 4, + + /** + * @generated from enum value: TARGET_TYPE_ONEOF = 5; + */ + TARGET_TYPE_ONEOF = 5, + + /** + * @generated from enum value: TARGET_TYPE_ENUM = 6; + */ + TARGET_TYPE_ENUM = 6, + + /** + * @generated from enum value: TARGET_TYPE_ENUM_ENTRY = 7; + */ + TARGET_TYPE_ENUM_ENTRY = 7, + + /** + * @generated from enum value: TARGET_TYPE_SERVICE = 8; + */ + TARGET_TYPE_SERVICE = 8, + + /** + * @generated from enum value: TARGET_TYPE_METHOD = 9; + */ + TARGET_TYPE_METHOD = 9, +} + +/** + * Describes the enum google.protobuf.FieldOptions.OptionTargetType. + */ +export const FieldOptions_OptionTargetTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 12, 3); + +/** + * @generated from message google.protobuf.OneofOptions + */ +export type OneofOptions = Message<"google.protobuf.OneofOptions"> & { + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 1; + */ + features?: FeatureSet | undefined; + + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; +}; + +/** + * Describes the message google.protobuf.OneofOptions. + * Use `create(OneofOptionsSchema)` to create a new message. + */ +export const OneofOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 13); + +/** + * @generated from message google.protobuf.EnumOptions + */ +export type EnumOptions = Message<"google.protobuf.EnumOptions"> & { + /** + * Set this option to true to allow mapping different tag names to the same + * value. + * + * @generated from field: optional bool allow_alias = 2; + */ + allowAlias: boolean; + + /** + * Is this enum deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the enum, or it will be completely ignored; in the very least, this + * is a formalization for deprecating enums. + * + * @generated from field: optional bool deprecated = 3 [default = false]; + */ + deprecated: boolean; + + /** + * Enable the legacy handling of JSON field name conflicts. This lowercases + * and strips underscored from the fields before comparison in proto3 only. + * The new behavior takes `json_name` into account and applies to proto2 as + * well. + * TODO Remove this legacy behavior once downstream teams have + * had time to migrate. + * + * @generated from field: optional bool deprecated_legacy_json_field_conflicts = 6 [deprecated = true]; + * @deprecated + */ + deprecatedLegacyJsonFieldConflicts: boolean; + + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 7; + */ + features?: FeatureSet | undefined; + + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; +}; + +/** + * Describes the message google.protobuf.EnumOptions. + * Use `create(EnumOptionsSchema)` to create a new message. + */ +export const EnumOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 14); + +/** + * @generated from message google.protobuf.EnumValueOptions + */ +export type EnumValueOptions = Message<"google.protobuf.EnumValueOptions"> & { + /** + * Is this enum value deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the enum value, or it will be completely ignored; in the very least, + * this is a formalization for deprecating enum values. + * + * @generated from field: optional bool deprecated = 1 [default = false]; + */ + deprecated: boolean; + + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 2; + */ + features?: FeatureSet | undefined; + + /** + * Indicate that fields annotated with this enum value should not be printed + * out when using debug formats, e.g. when the field contains sensitive + * credentials. + * + * @generated from field: optional bool debug_redact = 3 [default = false]; + */ + debugRedact: boolean; + + /** + * Information about the support window of a feature value. + * + * @generated from field: optional google.protobuf.FieldOptions.FeatureSupport feature_support = 4; + */ + featureSupport?: FieldOptions_FeatureSupport | undefined; + + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; +}; + +/** + * Describes the message google.protobuf.EnumValueOptions. + * Use `create(EnumValueOptionsSchema)` to create a new message. + */ +export const EnumValueOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 15); + +/** + * @generated from message google.protobuf.ServiceOptions + */ +export type ServiceOptions = Message<"google.protobuf.ServiceOptions"> & { + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 34; + */ + features?: FeatureSet | undefined; + + /** + * Is this service deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the service, or it will be completely ignored; in the very least, + * this is a formalization for deprecating services. + * + * @generated from field: optional bool deprecated = 33 [default = false]; + */ + deprecated: boolean; + + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; +}; + +/** + * Describes the message google.protobuf.ServiceOptions. + * Use `create(ServiceOptionsSchema)` to create a new message. + */ +export const ServiceOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 16); + +/** + * @generated from message google.protobuf.MethodOptions + */ +export type MethodOptions = Message<"google.protobuf.MethodOptions"> & { + /** + * Is this method deprecated? + * Depending on the target platform, this can emit Deprecated annotations + * for the method, or it will be completely ignored; in the very least, + * this is a formalization for deprecating methods. + * + * @generated from field: optional bool deprecated = 33 [default = false]; + */ + deprecated: boolean; + + /** + * @generated from field: optional google.protobuf.MethodOptions.IdempotencyLevel idempotency_level = 34 [default = IDEMPOTENCY_UNKNOWN]; + */ + idempotencyLevel: MethodOptions_IdempotencyLevel; + + /** + * Any features defined in the specific edition. + * + * @generated from field: optional google.protobuf.FeatureSet features = 35; + */ + features?: FeatureSet | undefined; + + /** + * The parser stores options it doesn't recognize here. See above. + * + * @generated from field: repeated google.protobuf.UninterpretedOption uninterpreted_option = 999; + */ + uninterpretedOption: UninterpretedOption[]; +}; + +/** + * Describes the message google.protobuf.MethodOptions. + * Use `create(MethodOptionsSchema)` to create a new message. + */ +export const MethodOptionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 17); + +/** + * Is this method side-effect-free (or safe in HTTP parlance), or idempotent, + * or neither? HTTP based RPC implementation may choose GET verb for safe + * methods, and PUT verb for idempotent methods instead of the default POST. + * + * @generated from enum google.protobuf.MethodOptions.IdempotencyLevel + */ +export enum MethodOptions_IdempotencyLevel { + /** + * @generated from enum value: IDEMPOTENCY_UNKNOWN = 0; + */ + IDEMPOTENCY_UNKNOWN = 0, + + /** + * implies idempotent + * + * @generated from enum value: NO_SIDE_EFFECTS = 1; + */ + NO_SIDE_EFFECTS = 1, + + /** + * idempotent, but may have side effects + * + * @generated from enum value: IDEMPOTENT = 2; + */ + IDEMPOTENT = 2, +} + +/** + * Describes the enum google.protobuf.MethodOptions.IdempotencyLevel. + */ +export const MethodOptions_IdempotencyLevelSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 17, 0); + +/** + * A message representing a option the parser does not recognize. This only + * appears in options protos created by the compiler::Parser class. + * DescriptorPool resolves these when building Descriptor objects. Therefore, + * options protos in descriptor objects (e.g. returned by Descriptor::options(), + * or produced by Descriptor::CopyTo()) will never have UninterpretedOptions + * in them. + * + * @generated from message google.protobuf.UninterpretedOption + */ +export type UninterpretedOption = Message<"google.protobuf.UninterpretedOption"> & { + /** + * @generated from field: repeated google.protobuf.UninterpretedOption.NamePart name = 2; + */ + name: UninterpretedOption_NamePart[]; + + /** + * The value of the uninterpreted option, in whatever type the tokenizer + * identified it as during parsing. Exactly one of these should be set. + * + * @generated from field: optional string identifier_value = 3; + */ + identifierValue: string; + + /** + * @generated from field: optional uint64 positive_int_value = 4; + */ + positiveIntValue: bigint; + + /** + * @generated from field: optional int64 negative_int_value = 5; + */ + negativeIntValue: bigint; + + /** + * @generated from field: optional double double_value = 6; + */ + doubleValue: number; + + /** + * @generated from field: optional bytes string_value = 7; + */ + stringValue: Uint8Array; + + /** + * @generated from field: optional string aggregate_value = 8; + */ + aggregateValue: string; +}; + +/** + * Describes the message google.protobuf.UninterpretedOption. + * Use `create(UninterpretedOptionSchema)` to create a new message. + */ +export const UninterpretedOptionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 18); + +/** + * The name of the uninterpreted option. Each string represents a segment in + * a dot-separated name. is_extension is true iff a segment represents an + * extension (denoted with parentheses in options specs in .proto files). + * E.g.,{ ["foo", false], ["bar.baz", true], ["moo", false] } represents + * "foo.(bar.baz).moo". + * + * @generated from message google.protobuf.UninterpretedOption.NamePart + */ +export type UninterpretedOption_NamePart = Message<"google.protobuf.UninterpretedOption.NamePart"> & { + /** + * @generated from field: required string name_part = 1; + */ + namePart: string; + + /** + * @generated from field: required bool is_extension = 2; + */ + isExtension: boolean; +}; + +/** + * Describes the message google.protobuf.UninterpretedOption.NamePart. + * Use `create(UninterpretedOption_NamePartSchema)` to create a new message. + */ +export const UninterpretedOption_NamePartSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 18, 0); + +/** + * TODO Enums in C++ gencode (and potentially other languages) are + * not well scoped. This means that each of the feature enums below can clash + * with each other. The short names we've chosen maximize call-site + * readability, but leave us very open to this scenario. A future feature will + * be designed and implemented to handle this, hopefully before we ever hit a + * conflict here. + * + * @generated from message google.protobuf.FeatureSet + */ +export type FeatureSet = Message<"google.protobuf.FeatureSet"> & { + /** + * @generated from field: optional google.protobuf.FeatureSet.FieldPresence field_presence = 1; + */ + fieldPresence: FeatureSet_FieldPresence; + + /** + * @generated from field: optional google.protobuf.FeatureSet.EnumType enum_type = 2; + */ + enumType: FeatureSet_EnumType; + + /** + * @generated from field: optional google.protobuf.FeatureSet.RepeatedFieldEncoding repeated_field_encoding = 3; + */ + repeatedFieldEncoding: FeatureSet_RepeatedFieldEncoding; + + /** + * @generated from field: optional google.protobuf.FeatureSet.Utf8Validation utf8_validation = 4; + */ + utf8Validation: FeatureSet_Utf8Validation; + + /** + * @generated from field: optional google.protobuf.FeatureSet.MessageEncoding message_encoding = 5; + */ + messageEncoding: FeatureSet_MessageEncoding; + + /** + * @generated from field: optional google.protobuf.FeatureSet.JsonFormat json_format = 6; + */ + jsonFormat: FeatureSet_JsonFormat; +}; + +/** + * Describes the message google.protobuf.FeatureSet. + * Use `create(FeatureSetSchema)` to create a new message. + */ +export const FeatureSetSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 19); + +/** + * @generated from enum google.protobuf.FeatureSet.FieldPresence + */ +export enum FeatureSet_FieldPresence { + /** + * @generated from enum value: FIELD_PRESENCE_UNKNOWN = 0; + */ + FIELD_PRESENCE_UNKNOWN = 0, + + /** + * @generated from enum value: EXPLICIT = 1; + */ + EXPLICIT = 1, + + /** + * @generated from enum value: IMPLICIT = 2; + */ + IMPLICIT = 2, + + /** + * @generated from enum value: LEGACY_REQUIRED = 3; + */ + LEGACY_REQUIRED = 3, +} + +/** + * Describes the enum google.protobuf.FeatureSet.FieldPresence. + */ +export const FeatureSet_FieldPresenceSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 19, 0); + +/** + * @generated from enum google.protobuf.FeatureSet.EnumType + */ +export enum FeatureSet_EnumType { + /** + * @generated from enum value: ENUM_TYPE_UNKNOWN = 0; + */ + ENUM_TYPE_UNKNOWN = 0, + + /** + * @generated from enum value: OPEN = 1; + */ + OPEN = 1, + + /** + * @generated from enum value: CLOSED = 2; + */ + CLOSED = 2, +} + +/** + * Describes the enum google.protobuf.FeatureSet.EnumType. + */ +export const FeatureSet_EnumTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 19, 1); + +/** + * @generated from enum google.protobuf.FeatureSet.RepeatedFieldEncoding + */ +export enum FeatureSet_RepeatedFieldEncoding { + /** + * @generated from enum value: REPEATED_FIELD_ENCODING_UNKNOWN = 0; + */ + REPEATED_FIELD_ENCODING_UNKNOWN = 0, + + /** + * @generated from enum value: PACKED = 1; + */ + PACKED = 1, + + /** + * @generated from enum value: EXPANDED = 2; + */ + EXPANDED = 2, +} + +/** + * Describes the enum google.protobuf.FeatureSet.RepeatedFieldEncoding. + */ +export const FeatureSet_RepeatedFieldEncodingSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 19, 2); + +/** + * @generated from enum google.protobuf.FeatureSet.Utf8Validation + */ +export enum FeatureSet_Utf8Validation { + /** + * @generated from enum value: UTF8_VALIDATION_UNKNOWN = 0; + */ + UTF8_VALIDATION_UNKNOWN = 0, + + /** + * @generated from enum value: VERIFY = 2; + */ + VERIFY = 2, + + /** + * @generated from enum value: NONE = 3; + */ + NONE = 3, +} + +/** + * Describes the enum google.protobuf.FeatureSet.Utf8Validation. + */ +export const FeatureSet_Utf8ValidationSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 19, 3); + +/** + * @generated from enum google.protobuf.FeatureSet.MessageEncoding + */ +export enum FeatureSet_MessageEncoding { + /** + * @generated from enum value: MESSAGE_ENCODING_UNKNOWN = 0; + */ + MESSAGE_ENCODING_UNKNOWN = 0, + + /** + * @generated from enum value: LENGTH_PREFIXED = 1; + */ + LENGTH_PREFIXED = 1, + + /** + * @generated from enum value: DELIMITED = 2; + */ + DELIMITED = 2, +} + +/** + * Describes the enum google.protobuf.FeatureSet.MessageEncoding. + */ +export const FeatureSet_MessageEncodingSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 19, 4); + +/** + * @generated from enum google.protobuf.FeatureSet.JsonFormat + */ +export enum FeatureSet_JsonFormat { + /** + * @generated from enum value: JSON_FORMAT_UNKNOWN = 0; + */ + JSON_FORMAT_UNKNOWN = 0, + + /** + * @generated from enum value: ALLOW = 1; + */ + ALLOW = 1, + + /** + * @generated from enum value: LEGACY_BEST_EFFORT = 2; + */ + LEGACY_BEST_EFFORT = 2, +} + +/** + * Describes the enum google.protobuf.FeatureSet.JsonFormat. + */ +export const FeatureSet_JsonFormatSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 19, 5); + +/** + * A compiled specification for the defaults of a set of features. These + * messages are generated from FeatureSet extensions and can be used to seed + * feature resolution. The resolution with this object becomes a simple search + * for the closest matching edition, followed by proto merges. + * + * @generated from message google.protobuf.FeatureSetDefaults + */ +export type FeatureSetDefaults = Message<"google.protobuf.FeatureSetDefaults"> & { + /** + * @generated from field: repeated google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault defaults = 1; + */ + defaults: FeatureSetDefaults_FeatureSetEditionDefault[]; + + /** + * The minimum supported edition (inclusive) when this was constructed. + * Editions before this will not have defaults. + * + * @generated from field: optional google.protobuf.Edition minimum_edition = 4; + */ + minimumEdition: Edition; + + /** + * The maximum known edition (inclusive) when this was constructed. Editions + * after this will not have reliable defaults. + * + * @generated from field: optional google.protobuf.Edition maximum_edition = 5; + */ + maximumEdition: Edition; +}; + +/** + * Describes the message google.protobuf.FeatureSetDefaults. + * Use `create(FeatureSetDefaultsSchema)` to create a new message. + */ +export const FeatureSetDefaultsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 20); + +/** + * A map from every known edition with a unique set of defaults to its + * defaults. Not all editions may be contained here. For a given edition, + * the defaults at the closest matching edition ordered at or before it should + * be used. This field must be in strict ascending order by edition. + * + * @generated from message google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault + */ +export type FeatureSetDefaults_FeatureSetEditionDefault = Message<"google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault"> & { + /** + * @generated from field: optional google.protobuf.Edition edition = 3; + */ + edition: Edition; + + /** + * Defaults of features that can be overridden in this edition. + * + * @generated from field: optional google.protobuf.FeatureSet overridable_features = 4; + */ + overridableFeatures?: FeatureSet | undefined; + + /** + * Defaults of features that can't be overridden in this edition. + * + * @generated from field: optional google.protobuf.FeatureSet fixed_features = 5; + */ + fixedFeatures?: FeatureSet | undefined; +}; + +/** + * Describes the message google.protobuf.FeatureSetDefaults.FeatureSetEditionDefault. + * Use `create(FeatureSetDefaults_FeatureSetEditionDefaultSchema)` to create a new message. + */ +export const FeatureSetDefaults_FeatureSetEditionDefaultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 20, 0); + +/** + * Encapsulates information about the original source file from which a + * FileDescriptorProto was generated. + * + * @generated from message google.protobuf.SourceCodeInfo + */ +export type SourceCodeInfo = Message<"google.protobuf.SourceCodeInfo"> & { + /** + * A Location identifies a piece of source code in a .proto file which + * corresponds to a particular definition. This information is intended + * to be useful to IDEs, code indexers, documentation generators, and similar + * tools. + * + * For example, say we have a file like: + * message Foo { + * optional string foo = 1; + * } + * Let's look at just the field definition: + * optional string foo = 1; + * ^ ^^ ^^ ^ ^^^ + * a bc de f ghi + * We have the following locations: + * span path represents + * [a,i) [ 4, 0, 2, 0 ] The whole field definition. + * [a,b) [ 4, 0, 2, 0, 4 ] The label (optional). + * [c,d) [ 4, 0, 2, 0, 5 ] The type (string). + * [e,f) [ 4, 0, 2, 0, 1 ] The name (foo). + * [g,h) [ 4, 0, 2, 0, 3 ] The number (1). + * + * Notes: + * - A location may refer to a repeated field itself (i.e. not to any + * particular index within it). This is used whenever a set of elements are + * logically enclosed in a single code segment. For example, an entire + * extend block (possibly containing multiple extension definitions) will + * have an outer location whose path refers to the "extensions" repeated + * field without an index. + * - Multiple locations may have the same path. This happens when a single + * logical declaration is spread out across multiple places. The most + * obvious example is the "extend" block again -- there may be multiple + * extend blocks in the same scope, each of which will have the same path. + * - A location's span is not always a subset of its parent's span. For + * example, the "extendee" of an extension declaration appears at the + * beginning of the "extend" block and is shared by all extensions within + * the block. + * - Just because a location's span is a subset of some other location's span + * does not mean that it is a descendant. For example, a "group" defines + * both a type and a field in a single declaration. Thus, the locations + * corresponding to the type and field and their components will overlap. + * - Code which tries to interpret locations should probably be designed to + * ignore those that it doesn't understand, as more types of locations could + * be recorded in the future. + * + * @generated from field: repeated google.protobuf.SourceCodeInfo.Location location = 1; + */ + location: SourceCodeInfo_Location[]; +}; + +/** + * Describes the message google.protobuf.SourceCodeInfo. + * Use `create(SourceCodeInfoSchema)` to create a new message. + */ +export const SourceCodeInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 21); + +/** + * @generated from message google.protobuf.SourceCodeInfo.Location + */ +export type SourceCodeInfo_Location = Message<"google.protobuf.SourceCodeInfo.Location"> & { + /** + * Identifies which part of the FileDescriptorProto was defined at this + * location. + * + * Each element is a field number or an index. They form a path from + * the root FileDescriptorProto to the place where the definition appears. + * For example, this path: + * [ 4, 3, 2, 7, 1 ] + * refers to: + * file.message_type(3) // 4, 3 + * .field(7) // 2, 7 + * .name() // 1 + * This is because FileDescriptorProto.message_type has field number 4: + * repeated DescriptorProto message_type = 4; + * and DescriptorProto.field has field number 2: + * repeated FieldDescriptorProto field = 2; + * and FieldDescriptorProto.name has field number 1: + * optional string name = 1; + * + * Thus, the above path gives the location of a field name. If we removed + * the last element: + * [ 4, 3, 2, 7 ] + * this path refers to the whole field declaration (from the beginning + * of the label to the terminating semicolon). + * + * @generated from field: repeated int32 path = 1 [packed = true]; + */ + path: number[]; + + /** + * Always has exactly three or four elements: start line, start column, + * end line (optional, otherwise assumed same as start line), end column. + * These are packed into a single field for efficiency. Note that line + * and column numbers are zero-based -- typically you will want to add + * 1 to each before displaying to a user. + * + * @generated from field: repeated int32 span = 2 [packed = true]; + */ + span: number[]; + + /** + * If this SourceCodeInfo represents a complete declaration, these are any + * comments appearing before and after the declaration which appear to be + * attached to the declaration. + * + * A series of line comments appearing on consecutive lines, with no other + * tokens appearing on those lines, will be treated as a single comment. + * + * leading_detached_comments will keep paragraphs of comments that appear + * before (but not connected to) the current element. Each paragraph, + * separated by empty lines, will be one comment element in the repeated + * field. + * + * Only the comment content is provided; comment markers (e.g. //) are + * stripped out. For block comments, leading whitespace and an asterisk + * will be stripped from the beginning of each line other than the first. + * Newlines are included in the output. + * + * Examples: + * + * optional int32 foo = 1; // Comment attached to foo. + * // Comment attached to bar. + * optional int32 bar = 2; + * + * optional string baz = 3; + * // Comment attached to baz. + * // Another line attached to baz. + * + * // Comment attached to moo. + * // + * // Another line attached to moo. + * optional double moo = 4; + * + * // Detached comment for corge. This is not leading or trailing comments + * // to moo or corge because there are blank lines separating it from + * // both. + * + * // Detached comment for corge paragraph 2. + * + * optional string corge = 5; + * /* Block comment attached + * * to corge. Leading asterisks + * * will be removed. *\/ + * /* Block comment attached to + * * grault. *\/ + * optional int32 grault = 6; + * + * // ignored detached comments. + * + * @generated from field: optional string leading_comments = 3; + */ + leadingComments: string; + + /** + * @generated from field: optional string trailing_comments = 4; + */ + trailingComments: string; + + /** + * @generated from field: repeated string leading_detached_comments = 6; + */ + leadingDetachedComments: string[]; +}; + +/** + * Describes the message google.protobuf.SourceCodeInfo.Location. + * Use `create(SourceCodeInfo_LocationSchema)` to create a new message. + */ +export const SourceCodeInfo_LocationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 21, 0); + +/** + * Describes the relationship between generated code and its original source + * file. A GeneratedCodeInfo message is associated with only one generated + * source file, but may contain references to different source .proto files. + * + * @generated from message google.protobuf.GeneratedCodeInfo + */ +export type GeneratedCodeInfo = Message<"google.protobuf.GeneratedCodeInfo"> & { + /** + * An Annotation connects some span of text in generated code to an element + * of its generating .proto file. + * + * @generated from field: repeated google.protobuf.GeneratedCodeInfo.Annotation annotation = 1; + */ + annotation: GeneratedCodeInfo_Annotation[]; +}; + +/** + * Describes the message google.protobuf.GeneratedCodeInfo. + * Use `create(GeneratedCodeInfoSchema)` to create a new message. + */ +export const GeneratedCodeInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 22); + +/** + * @generated from message google.protobuf.GeneratedCodeInfo.Annotation + */ +export type GeneratedCodeInfo_Annotation = Message<"google.protobuf.GeneratedCodeInfo.Annotation"> & { + /** + * Identifies the element in the original source .proto file. This field + * is formatted the same as SourceCodeInfo.Location.path. + * + * @generated from field: repeated int32 path = 1 [packed = true]; + */ + path: number[]; + + /** + * Identifies the filesystem path to the original source .proto. + * + * @generated from field: optional string source_file = 2; + */ + sourceFile: string; + + /** + * Identifies the starting offset in bytes in the generated code + * that relates to the identified object. + * + * @generated from field: optional int32 begin = 3; + */ + begin: number; + + /** + * Identifies the ending offset in bytes in the generated code that + * relates to the identified object. The end offset should be one past + * the last relevant byte (so the length of the text = end - begin). + * + * @generated from field: optional int32 end = 4; + */ + end: number; + + /** + * @generated from field: optional google.protobuf.GeneratedCodeInfo.Annotation.Semantic semantic = 5; + */ + semantic: GeneratedCodeInfo_Annotation_Semantic; +}; + +/** + * Describes the message google.protobuf.GeneratedCodeInfo.Annotation. + * Use `create(GeneratedCodeInfo_AnnotationSchema)` to create a new message. + */ +export const GeneratedCodeInfo_AnnotationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_descriptor, 22, 0); + +/** + * Represents the identified object's effect on the element in the original + * .proto file. + * + * @generated from enum google.protobuf.GeneratedCodeInfo.Annotation.Semantic + */ +export enum GeneratedCodeInfo_Annotation_Semantic { + /** + * There is no effect or the effect is indescribable. + * + * @generated from enum value: NONE = 0; + */ + NONE = 0, + + /** + * The element is set or otherwise mutated. + * + * @generated from enum value: SET = 1; + */ + SET = 1, + + /** + * An alias to the element is returned. + * + * @generated from enum value: ALIAS = 2; + */ + ALIAS = 2, +} + +/** + * Describes the enum google.protobuf.GeneratedCodeInfo.Annotation.Semantic. + */ +export const GeneratedCodeInfo_Annotation_SemanticSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 22, 0, 0); + +/** + * The full set of known editions. + * + * @generated from enum google.protobuf.Edition + */ +export enum Edition { + /** + * A placeholder for an unknown edition value. + * + * @generated from enum value: EDITION_UNKNOWN = 0; + */ + EDITION_UNKNOWN = 0, + + /** + * A placeholder edition for specifying default behaviors *before* a feature + * was first introduced. This is effectively an "infinite past". + * + * @generated from enum value: EDITION_LEGACY = 900; + */ + EDITION_LEGACY = 900, + + /** + * Legacy syntax "editions". These pre-date editions, but behave much like + * distinct editions. These can't be used to specify the edition of proto + * files, but feature definitions must supply proto2/proto3 defaults for + * backwards compatibility. + * + * @generated from enum value: EDITION_PROTO2 = 998; + */ + EDITION_PROTO2 = 998, + + /** + * @generated from enum value: EDITION_PROTO3 = 999; + */ + EDITION_PROTO3 = 999, + + /** + * Editions that have been released. The specific values are arbitrary and + * should not be depended on, but they will always be time-ordered for easy + * comparison. + * + * @generated from enum value: EDITION_2023 = 1000; + */ + EDITION_2023 = 1000, + + /** + * @generated from enum value: EDITION_2024 = 1001; + */ + EDITION_2024 = 1001, + + /** + * Placeholder editions for testing feature resolution. These should not be + * used or relyed on outside of tests. + * + * @generated from enum value: EDITION_1_TEST_ONLY = 1; + */ + EDITION_1_TEST_ONLY = 1, + + /** + * @generated from enum value: EDITION_2_TEST_ONLY = 2; + */ + EDITION_2_TEST_ONLY = 2, + + /** + * @generated from enum value: EDITION_99997_TEST_ONLY = 99997; + */ + EDITION_99997_TEST_ONLY = 99997, + + /** + * @generated from enum value: EDITION_99998_TEST_ONLY = 99998; + */ + EDITION_99998_TEST_ONLY = 99998, + + /** + * @generated from enum value: EDITION_99999_TEST_ONLY = 99999; + */ + EDITION_99999_TEST_ONLY = 99999, + + /** + * Placeholder for specifying unbounded edition support. This should only + * ever be used by plugins that can expect to never require any changes to + * support a new edition. + * + * @generated from enum value: EDITION_MAX = 2147483647; + */ + EDITION_MAX = 2147483647, +} + +/** + * Describes the enum google.protobuf.Edition. + */ +export const EditionSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_google_protobuf_descriptor, 0); + diff --git a/examples/8_typescript_standalone/src/generated/google/protobuf/timestamp_pb.ts b/examples/8_typescript_standalone/src/generated/google/protobuf/timestamp_pb.ts new file mode 100644 index 0000000..3b1cedf --- /dev/null +++ b/examples/8_typescript_standalone/src/generated/google/protobuf/timestamp_pb.ts @@ -0,0 +1,150 @@ +// Copyright 2020-2024 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.12.0 with parameter "import_extension=js,target=ts" +// @generated from file google/protobuf/timestamp.proto (package google.protobuf, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file google/protobuf/timestamp.proto. + */ +export const file_google_protobuf_timestamp: GenFile = /*@__PURE__*/ + fileDesc("Ch9nb29nbGUvcHJvdG9idWYvdGltZXN0YW1wLnByb3RvEg9nb29nbGUucHJvdG9idWYiKwoJVGltZXN0YW1wEg8KB3NlY29uZHMYASABKAMSDQoFbmFub3MYAiABKAVChQEKE2NvbS5nb29nbGUucHJvdG9idWZCDlRpbWVzdGFtcFByb3RvUAFaMmdvb2dsZS5nb2xhbmcub3JnL3Byb3RvYnVmL3R5cGVzL2tub3duL3RpbWVzdGFtcHBi+AEBogIDR1BCqgIeR29vZ2xlLlByb3RvYnVmLldlbGxLbm93blR5cGVzYgZwcm90bzM"); + +/** + * A Timestamp represents a point in time independent of any time zone or local + * calendar, encoded as a count of seconds and fractions of seconds at + * nanosecond resolution. The count is relative to an epoch at UTC midnight on + * January 1, 1970, in the proleptic Gregorian calendar which extends the + * Gregorian calendar backwards to year one. + * + * All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap + * second table is needed for interpretation, using a [24-hour linear + * smear](https://developers.google.com/time/smear). + * + * The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By + * restricting to that range, we ensure that we can convert to and from [RFC + * 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings. + * + * # Examples + * + * Example 1: Compute Timestamp from POSIX `time()`. + * + * Timestamp timestamp; + * timestamp.set_seconds(time(NULL)); + * timestamp.set_nanos(0); + * + * Example 2: Compute Timestamp from POSIX `gettimeofday()`. + * + * struct timeval tv; + * gettimeofday(&tv, NULL); + * + * Timestamp timestamp; + * timestamp.set_seconds(tv.tv_sec); + * timestamp.set_nanos(tv.tv_usec * 1000); + * + * Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`. + * + * FILETIME ft; + * GetSystemTimeAsFileTime(&ft); + * UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime; + * + * // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z + * // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z. + * Timestamp timestamp; + * timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL)); + * timestamp.set_nanos((INT32) ((ticks % 10000000) * 100)); + * + * Example 4: Compute Timestamp from Java `System.currentTimeMillis()`. + * + * long millis = System.currentTimeMillis(); + * + * Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000) + * .setNanos((int) ((millis % 1000) * 1000000)).build(); + * + * Example 5: Compute Timestamp from Java `Instant.now()`. + * + * Instant now = Instant.now(); + * + * Timestamp timestamp = + * Timestamp.newBuilder().setSeconds(now.getEpochSecond()) + * .setNanos(now.getNano()).build(); + * + * Example 6: Compute Timestamp from current time in Python. + * + * timestamp = Timestamp() + * timestamp.GetCurrentTime() + * + * # JSON Mapping + * + * In JSON format, the Timestamp type is encoded as a string in the + * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the + * format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z" + * where {year} is always expressed using four digits while {month}, {day}, + * {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional + * seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution), + * are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone + * is required. A proto3 JSON serializer should always use UTC (as indicated by + * "Z") when printing the Timestamp type and a proto3 JSON parser should be + * able to accept both UTC and other timezones (as indicated by an offset). + * + * For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past + * 01:30 UTC on January 15, 2017. + * + * In JavaScript, one can convert a Date object to this format using the + * standard + * [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) + * method. In Python, a standard `datetime.datetime` object can be converted + * to this format using + * [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with + * the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use + * the Joda Time's [`ISODateTimeFormat.dateTime()`]( + * http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime() + * ) to obtain a formatter capable of generating timestamps in this format. + * + * + * @generated from message google.protobuf.Timestamp + */ +export type Timestamp = Message<"google.protobuf.Timestamp"> & { + /** + * Represents seconds of UTC time since Unix epoch + * 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + * 9999-12-31T23:59:59Z inclusive. + * + * @generated from field: int64 seconds = 1; + */ + seconds: bigint; + + /** + * Non-negative fractions of a second at nanosecond resolution. Negative + * second values with fractions must still have non-negative nanos values + * that count forward in time. Must be from 0 to 999,999,999 + * inclusive. + * + * @generated from field: int32 nanos = 2; + */ + nanos: number; +}; + +/** + * Describes the message google.protobuf.Timestamp. + * Use `create(TimestampSchema)` to create a new message. + */ +export const TimestampSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_protobuf_timestamp, 0); + From 0645ad16369f1f59971d71203d67429688f3396b Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 19:54:20 +0300 Subject: [PATCH 53/74] feat(11-02): add standalone TypeScript stdio server --- .../8_typescript_standalone/src/server.ts | 86 ++++++++++++ examples/node_stdio_test.go | 124 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 examples/8_typescript_standalone/src/server.ts create mode 100644 examples/node_stdio_test.go diff --git a/examples/8_typescript_standalone/src/server.ts b/examples/8_typescript_standalone/src/server.ts new file mode 100644 index 0000000..22e0c0b --- /dev/null +++ b/examples/8_typescript_standalone/src/server.ts @@ -0,0 +1,86 @@ +import { create } from "@bufbuild/protobuf"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { TimestampSchema } from "./generated/google/protobuf/timestamp_pb.js"; +import { registerNotebookAPITools, type NotebookAPIToolHandler, type ToolRequestContext } from "./generated/proto/notebook_mcp.js"; +import { + CreateNoteResponseSchema, + HealthResponseSchema, + NoteSchema, + SearchNotesResponseSchema, + type CreateNoteRequest, + type CreateNoteResponse, + type HealthRequest, + type HealthResponse, + type Note, + type SearchNotesRequest, + type SearchNotesResponse, +} from "./generated/proto/notebook_pb.js"; + +function nowTimestamp() { + const millis = Date.now(); + return create(TimestampSchema, { + seconds: BigInt(Math.floor(millis / 1000)), + nanos: (millis % 1000) * 1_000_000, + }); +} + +class Notebook implements NotebookAPIToolHandler { + private nextID = 1; + private readonly notes = new Map(); + + createNote(_ctx: ToolRequestContext, request: CreateNoteRequest): CreateNoteResponse { + const note = create(NoteSchema, { + id: `note-${this.nextID}`, + title: request.title, + body: request.body, + tags: [...request.tags], + dueDate: request.dueDate, + createdAt: nowTimestamp(), + }); + + this.nextID += 1; + this.notes.set(note.id, note); + return create(CreateNoteResponseSchema, { note }); + } + + searchNotes(_ctx: ToolRequestContext, request: SearchNotesRequest): SearchNotesResponse { + const query = request.query?.toLocaleLowerCase() ?? ""; + const requiredTags = new Set(request.tags); + const limit = request.limit ?? 10; + const notes: Note[] = []; + + for (const note of this.notes.values()) { + const haystack = `${note.title}\n${note.body}`.toLocaleLowerCase(); + if (query !== "" && !haystack.includes(query)) { + continue; + } + if (requiredTags.size > 0 && ![...requiredTags].every((tag) => note.tags.includes(tag))) { + continue; + } + notes.push(note); + if (notes.length >= limit) { + break; + } + } + + return create(SearchNotesResponseSchema, { notes }); + } + + health(_ctx: ToolRequestContext, _request: HealthRequest): HealthResponse { + return create(HealthResponseSchema, { + ok: true, + noteCount: this.notes.size, + }); + } +} + +const server = new Server( + { name: "standalone-typescript-notebook", version: "0.1.0" }, + { capabilities: { tools: {} } }, +); +const notebook = new Notebook(); +registerNotebookAPITools(server, notebook, "notebook"); + +await server.connect(new StdioServerTransport()); diff --git a/examples/node_stdio_test.go b/examples/node_stdio_test.go new file mode 100644 index 0000000..b32a670 --- /dev/null +++ b/examples/node_stdio_test.go @@ -0,0 +1,124 @@ +package examples_test + +import ( + "context" + "os/exec" + "path/filepath" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestStandaloneTypeScriptExampleOverStdio(t *testing.T) { + root := repoRoot(t) + projectDir := filepath.Join(root, "examples/8_typescript_standalone") + buildStandaloneNodeExample(t, projectDir) + + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{ + Name: "protoc-gen-mcp-standalone-typescript-example-test-client", + Version: "v0.0.1", + }, nil) + + session, err := client.Connect(ctx, &mcp.CommandTransport{ + Command: standaloneNodeExampleCommand(projectDir, "dist/server.js"), + }, nil) + if err != nil { + t.Fatalf("client.Connect() over stdio failed: %v", err) + } + defer session.Close() + + runStandaloneNodeNotebookExample(t, session, "Ship TypeScript support", "typescript") +} + +func buildStandaloneNodeExample(t *testing.T, projectDir string) { + t.Helper() + + cmd := exec.Command("make", "build") + cmd.Dir = projectDir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("make build failed in %s:\n%s", projectDir, string(output)) + } +} + +func standaloneNodeExampleCommand(projectDir string, script string) *exec.Cmd { + cmd := exec.Command("node", script) + cmd.Dir = projectDir + return cmd +} + +func runStandaloneNodeNotebookExample(t *testing.T, session *mcp.ClientSession, title string, tag string) { + t.Helper() + + ctx := context.Background() + tools, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools() failed: %v", err) + } + findTool(t, tools.Tools, "notebook_CreateNote") + findTool(t, tools.Tools, "notebook_SearchNotes") + findTool(t, tools.Tools, "notebook_Health") + + createResult, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "notebook_CreateNote", + Arguments: map[string]any{ + "title": title, + "body": "Verify the generated Node MCP bindings are pleasant to use.", + "tags": []any{tag, "mcp"}, + "dueDate": "2026-05-30", + }, + }) + if err != nil { + t.Fatalf("CallTool(CreateNote) failed: %v", err) + } + if createResult.IsError { + t.Fatalf("CreateNote returned tool error: %+v", createResult) + } + assertTextStructuredContentMatch(t, "notebook_CreateNote", createResult) + createStructured := decodeMap(t, createResult.StructuredContent) + note, ok := createStructured["note"].(map[string]any) + if !ok { + t.Fatalf("created note has type %T, want map[string]any", createStructured["note"]) + } + if got := note["title"]; got != title { + t.Fatalf("created note.title = %v, want %s", got, title) + } + + searchResult, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "notebook_SearchNotes", + Arguments: map[string]any{ + "query": tag, + "tags": []any{"mcp"}, + "limit": 5, + }, + }) + if err != nil { + t.Fatalf("CallTool(SearchNotes) failed: %v", err) + } + if searchResult.IsError { + t.Fatalf("SearchNotes returned tool error: %+v", searchResult) + } + assertTextStructuredContentMatch(t, "notebook_SearchNotes", searchResult) + searchStructured := decodeMap(t, searchResult.StructuredContent) + notes, ok := searchStructured["notes"].([]any) + if !ok || len(notes) != 1 { + t.Fatalf("notes = %T %v, want one matching note", searchStructured["notes"], searchStructured["notes"]) + } + + healthResult, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "notebook_Health", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool(Health) failed: %v", err) + } + if healthResult.IsError { + t.Fatalf("Health returned tool error: %+v", healthResult) + } + assertTextStructuredContentMatch(t, "notebook_Health", healthResult) + healthStructured := decodeMap(t, healthResult.StructuredContent) + if got := healthStructured["noteCount"]; got != 1.0 { + t.Fatalf("health.noteCount = %v, want 1", got) + } +} From 0335a94a9c183ecf0c97b12dca5ba75855377226 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 20:03:06 +0300 Subject: [PATCH 54/74] feat(11-03): add standalone JavaScript consumption proof --- AGENTS.md | 28 +- examples/8_typescript_standalone/Makefile | 2 +- .../src/generated/proto/notebook_pb.ts | 2 +- examples/9_javascript_standalone/.gitignore | 1 + examples/9_javascript_standalone/Makefile | 13 + examples/9_javascript_standalone/README.md | 32 + .../9_javascript_standalone/package-lock.json | 1181 +++++++++++++++++ examples/9_javascript_standalone/package.json | 21 + .../9_javascript_standalone/src/server.js | 113 ++ .../9_javascript_standalone/tsconfig.json | 13 + examples/Makefile | 11 +- examples/README.md | 42 +- examples/node_stdio_test.go | 22 + 13 files changed, 1470 insertions(+), 11 deletions(-) create mode 100644 examples/9_javascript_standalone/.gitignore create mode 100644 examples/9_javascript_standalone/Makefile create mode 100644 examples/9_javascript_standalone/README.md create mode 100644 examples/9_javascript_standalone/package-lock.json create mode 100644 examples/9_javascript_standalone/package.json create mode 100644 examples/9_javascript_standalone/src/server.js create mode 100644 examples/9_javascript_standalone/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 763f9c3..99488be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,9 @@ architecture unless explicitly revised. - `examples`: standalone Go/Python/JVM integration projects; example directories use numeric underscore prefixes such as `1_helloworld`, `4_crm_system`, `5_python_standalone`, `6_java_standalone`, and - `7_kotlin_standalone`, plus the dedicated JVM workspace `examples/jvm` and - Node spike workspace `examples/node/sdk-spike` + `7_kotlin_standalone`, `8_typescript_standalone`, and + `9_javascript_standalone`, plus the dedicated JVM workspace `examples/jvm` + and Node spike workspace `examples/node/sdk-spike` - `examples/node/sdk-spike`: pinned local Node package scope for TypeScript SDK, Protobuf-ES, Ajv, package-lock-backed `npm ci`, and strict NodeNext compile checks @@ -64,6 +65,13 @@ architecture unless explicitly revised. its own Gradle build, `easyp.yaml`, protobuf contract, generated Java/Kotlin protobuf classes, generated `lang=kotlin` MCP sidecar, and handwritten stdio server +- `examples/8_typescript_standalone`: TypeScript user-style standalone project + with its own npm package, `tsconfig.json`, `easyp.yaml`, protobuf contract, + generated Protobuf-ES `_pb.ts` classes, generated `lang=typescript` MCP + sidecar, and handwritten stdio server +- `examples/9_javascript_standalone`: JavaScript consumption proof that imports + compiled `.js` output and `.d.ts` metadata from the TypeScript standalone + project without using direct `lang=javascript` - `examples/jvm`: isolated Gradle Kotlin DSL workspace that compiles generated Java/Kotlin JVM sidecars against Maven `protoc`, official MCP SDK artifacts, and the local `cmd/protoc-gen-mcp` binary @@ -319,6 +327,10 @@ architecture unless explicitly revised. `examples/6_java_standalone` and `examples/7_kotlin_standalone`, each with its own `easyp.yaml`, lockfile, Gradle build, handwritten stdio server, and generated-source cleanup for Google protobuf classes supplied by Maven +- standalone TypeScript and JavaScript Node projects under + `examples/8_typescript_standalone` and `examples/9_javascript_standalone`, + covering user-style `lang=typescript` generation with Protobuf-ES plus plain + JavaScript consumption of compiled generated `.js` and `.d.ts` output - repository docs now route JVM users through `examples/jvm/README.md`, and rollout messaging states that releases publish the `protoc-gen-mcp binary` while downstream JVM users compile generated sources against the official SDK @@ -356,6 +368,12 @@ architecture unless explicitly revised. for the standalone Java user-project generation and compile flow - `cd examples/7_kotlin_standalone && make lint && make clean build` for the standalone Kotlin user-project generation and compile flow +- `cd examples/8_typescript_standalone && make lint && make clean build` + for the standalone TypeScript user-project generation and compile flow +- `cd examples/9_javascript_standalone && make clean build` + for JavaScript consumption of compiled TypeScript target output +- `go test ./examples -run 'TestStandalone(TypeScript|JavaScript)ExampleOverStdio' -count=1` + for standalone Node stdio parity - `go test ./...` - stdio smoke tests via `internal/examplemcp/stdio_test.go` - `go test ./internal/examplemcp -run 'TestJava.*OverStdio' -count=1` @@ -438,6 +456,12 @@ architecture unless explicitly revised. - `cd examples/6_java_standalone && make build` - Build standalone Kotlin example: - `cd examples/7_kotlin_standalone && make build` +- Run standalone TypeScript example: + - `cd examples/8_typescript_standalone && make build && make run` +- Run standalone JavaScript example: + - `cd examples/9_javascript_standalone && make build && make run` +- Run standalone Node stdio tests: + - `go test ./examples -run 'TestStandalone(TypeScript|JavaScript)ExampleOverStdio' -count=1` - Build plugin: `go build ./cmd/protoc-gen-mcp` - Validate GoReleaser config: `goreleaser check` - Build example MCP server binary: diff --git a/examples/8_typescript_standalone/Makefile b/examples/8_typescript_standalone/Makefile index d21ebde..7f8b72f 100644 --- a/examples/8_typescript_standalone/Makefile +++ b/examples/8_typescript_standalone/Makefile @@ -10,7 +10,7 @@ lint: easyp --cfg easyp.yaml mod download easyp --cfg easyp.yaml lint -p proto -r . -build: setup +build: generate npm run typecheck npm run build diff --git a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts index 5c94c58..56b539a 100644 --- a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts +++ b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file proto/notebook.proto. */ export const file_proto_notebook: GenFile = /*@__PURE__*/ - fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsL8ACAQogU2VydmVyLWFzc2lnbmVkIG5vdGUgaWRlbnRpZmllci4aCAoGbm90ZS0xElQKBXRpdGxlGAIgASgJQkXatyxBGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0CiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLmABaFASdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuElEKBHRhZ3MYBCADKAlCQ9q3LD8aFyoVCgwKCnR5cGVzY3JpcHQKBQoDbWNwCiFPcHRpb25hbCB0YWdzIHVzZWQgZm9yIGZpbHRlcmluZy6AAgESWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAKKk9wdGlvbmFsIGR1ZSBkYXRlIGluIElTTyA4NjAxIGRhdGUgZm9ybWF0LloEZGF0ZRoMCgoyMDI2LTA1LTMwSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCjAAgEKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEFgAWhQGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0CiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLhJ3CgRib2R5GAIgASgJQmnatyxlaNAPGkQKQlZlcmlmeSB0aGF0IGdlbmVyYXRlZCBUeXBlU2NyaXB0IE1DUCBiaW5kaW5ncyBhcmUgcGxlYXNhbnQgdG8gdXNlLgoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAESUQoEdGFncxgDIAMoCUJD2rcsPxoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3AKIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLoACARJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuWgRkYXRlGgwKCjIwMjYtMDUtMzBIAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVgpET3B0aW9uYWwgY2FzZS1pbnNlbnNpdGl2ZSB0ZXh0IHF1ZXJ5IG1hdGNoZWQgYWdhaW5zdCB0aXRsZSBhbmQgYm9keS5gARoMCgp0eXBlc2NyaXB0SACIAQESVgoEdGFncxgCIAMoCUJI2rcsRAo0T3B0aW9uYWwgdGFnczsgYSBub3RlIG11c3QgaGF2ZSBldmVyeSByZXF1ZXN0ZWQgdGFnLoACARoJKgcKBQoDbWNwElQKBWxpbWl0GAMgASgFQkDatyw8CiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uoQEAAAAAAADwP6kBAAAAAAAASUAiAjgKSAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJ8ACAQoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LjLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMSC0NyZWF0ZSBub3RlGi5DcmVhdGUgYSBub3RlIGluIHRoZSBsb2NhbCBpbi1tZW1vcnkgbm90ZWJvb2suUgQgABAAEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0EgxTZWFyY2ggbm90ZXMaHlNlYXJjaCBub3RlcyBieSB0ZXh0IGFuZCB0YWdzLlIEIAAIARKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhIPTm90ZWJvb2sgaGVhbHRoGi1WZXJpZnkgdGhhdCB0aGUgbm90ZWJvb2sgTUNQIHNlcnZlciBpcyBhbGl2ZS5SBAgBIAAaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); + fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsL8ACAQogU2VydmVyLWFzc2lnbmVkIG5vdGUgaWRlbnRpZmllci4aCAoGbm90ZS0xElQKBXRpdGxlGAIgASgJQkXatyxBGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0CiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLmABaFASdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuElEKBHRhZ3MYBCADKAlCQ9q3LD8KIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLoACARoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3ASWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAaDAoKMjAyNi0wNS0zMAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuWgRkYXRlSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCjAAgEKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEEaGQoXU2hpcCBUeXBlU2NyaXB0IHN1cHBvcnQKIFNob3J0IGh1bWFuLXJlYWRhYmxlIG5vdGUgdGl0bGUuYAFoUBJ3CgRib2R5GAIgASgJQmnatyxlYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuChhOb3RlIGJvZHkgaW4gcGxhaW4gdGV4dC4SUQoEdGFncxgDIAMoCUJD2rcsPwohT3B0aW9uYWwgdGFncyB1c2VkIGZvciBmaWx0ZXJpbmcugAIBGhcqFQoMCgp0eXBlc2NyaXB0CgUKA21jcBJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuWgRkYXRlGgwKCjIwMjYtMDUtMzBIAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVgpET3B0aW9uYWwgY2FzZS1pbnNlbnNpdGl2ZSB0ZXh0IHF1ZXJ5IG1hdGNoZWQgYWdhaW5zdCB0aXRsZSBhbmQgYm9keS5gARoMCgp0eXBlc2NyaXB0SACIAQESVgoEdGFncxgCIAMoCUJI2rcsRAo0T3B0aW9uYWwgdGFnczsgYSBub3RlIG11c3QgaGF2ZSBldmVyeSByZXF1ZXN0ZWQgdGFnLoACARoJKgcKBQoDbWNwElQKBWxpbWl0GAMgASgFQkDatyw8oQEAAAAAAADwP6kBAAAAAAAASUAiAjgKCiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uSAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJ8ACAQoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LjLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMSC0NyZWF0ZSBub3RlGi5DcmVhdGUgYSBub3RlIGluIHRoZSBsb2NhbCBpbi1tZW1vcnkgbm90ZWJvb2suUgQQACAAEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0EgxTZWFyY2ggbm90ZXMaHlNlYXJjaCBub3RlcyBieSB0ZXh0IGFuZCB0YWdzLlIECAEgABKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhIPTm90ZWJvb2sgaGVhbHRoGi1WZXJpZnkgdGhhdCB0aGUgbm90ZWJvb2sgTUNQIHNlcnZlciBpcyBhbGl2ZS5SBAgBIAAaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); /** * @generated from message standalone.notebook.v1.Note diff --git a/examples/9_javascript_standalone/.gitignore b/examples/9_javascript_standalone/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/examples/9_javascript_standalone/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/examples/9_javascript_standalone/Makefile b/examples/9_javascript_standalone/Makefile new file mode 100644 index 0000000..a2755ad --- /dev/null +++ b/examples/9_javascript_standalone/Makefile @@ -0,0 +1,13 @@ +.PHONY: setup build run clean + +setup: + npm ci + +build: setup + npm run build + +run: build + npm run run + +clean: + rm -rf node_modules diff --git a/examples/9_javascript_standalone/README.md b/examples/9_javascript_standalone/README.md new file mode 100644 index 0000000..8463011 --- /dev/null +++ b/examples/9_javascript_standalone/README.md @@ -0,0 +1,32 @@ +# Standalone JavaScript MCP Server + +This example proves JavaScript users can consume compiled output from the +TypeScript target. It does not use `lang=javascript` and does not generate a +second JavaScript-specific sidecar. + +Instead, it imports compiled `.js` files and generated `.d.ts` metadata from +`../8_typescript_standalone/dist`: + +```js +import { registerNotebookAPITools } from "../../8_typescript_standalone/dist/generated/proto/notebook_mcp.js"; +``` + +The handwritten `src/server.js` uses `// @ts-check` and JSDoc imports so +TypeScript verifies the generated handler and protobuf types for plain +JavaScript code. + +## Build And Run + +```bash +make build +make run +``` + +`make build` first builds the TypeScript standalone example, then type-checks +this JavaScript server with `checkJs`. + +The server exposes: + +- `notebook_CreateNote` +- `notebook_SearchNotes` +- `notebook_Health` diff --git a/examples/9_javascript_standalone/package-lock.json b/examples/9_javascript_standalone/package-lock.json new file mode 100644 index 0000000..8146aca --- /dev/null +++ b/examples/9_javascript_standalone/package-lock.json @@ -0,0 +1,1181 @@ +{ + "name": "protoc-gen-mcp-javascript-standalone", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "protoc-gen-mcp-javascript-standalone", + "version": "0.0.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@modelcontextprotocol/sdk": "1.29.0", + "ajv": "8.20.0" + }, + "devDependencies": { + "@types/node": "24.12.2", + "typescript": "6.0.3" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/examples/9_javascript_standalone/package.json b/examples/9_javascript_standalone/package.json new file mode 100644 index 0000000..a7b6b33 --- /dev/null +++ b/examples/9_javascript_standalone/package.json @@ -0,0 +1,21 @@ +{ + "name": "protoc-gen-mcp-javascript-standalone", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "prepare:typescript-example": "npm --prefix ../8_typescript_standalone run build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build": "npm run prepare:typescript-example && npm run typecheck", + "run": "node src/server.js" + }, + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@modelcontextprotocol/sdk": "1.29.0", + "ajv": "8.20.0" + }, + "devDependencies": { + "@types/node": "24.12.2", + "typescript": "6.0.3" + } +} diff --git a/examples/9_javascript_standalone/src/server.js b/examples/9_javascript_standalone/src/server.js new file mode 100644 index 0000000..636bdd5 --- /dev/null +++ b/examples/9_javascript_standalone/src/server.js @@ -0,0 +1,113 @@ +// @ts-check + +import { create } from "@bufbuild/protobuf"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { TimestampSchema } from "../../8_typescript_standalone/dist/generated/google/protobuf/timestamp_pb.js"; +import { registerNotebookAPITools } from "../../8_typescript_standalone/dist/generated/proto/notebook_mcp.js"; +import { + CreateNoteResponseSchema, + HealthResponseSchema, + NoteSchema, + SearchNotesResponseSchema, +} from "../../8_typescript_standalone/dist/generated/proto/notebook_pb.js"; + +/** + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_mcp.js").NotebookAPIToolHandler} NotebookAPIToolHandler + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_mcp.js").ToolRequestContext} ToolRequestContext + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_pb.js").CreateNoteRequest} CreateNoteRequest + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_pb.js").CreateNoteResponse} CreateNoteResponse + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_pb.js").HealthRequest} HealthRequest + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_pb.js").HealthResponse} HealthResponse + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_pb.js").Note} Note + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_pb.js").SearchNotesRequest} SearchNotesRequest + * @typedef {import("../../8_typescript_standalone/dist/generated/proto/notebook_pb.js").SearchNotesResponse} SearchNotesResponse + */ + +function nowTimestamp() { + const millis = Date.now(); + return create(TimestampSchema, { + seconds: BigInt(Math.floor(millis / 1000)), + nanos: (millis % 1000) * 1_000_000, + }); +} + +/** @implements {NotebookAPIToolHandler} */ +class Notebook { + constructor() { + this.nextID = 1; + /** @type {Map} */ + this.notes = new Map(); + } + + /** + * @param {ToolRequestContext} _ctx + * @param {CreateNoteRequest} request + * @returns {CreateNoteResponse} + */ + createNote(_ctx, request) { + const note = create(NoteSchema, { + id: `note-${this.nextID}`, + title: request.title, + body: request.body, + tags: [...request.tags], + dueDate: request.dueDate, + createdAt: nowTimestamp(), + }); + + this.nextID += 1; + this.notes.set(note.id, note); + return create(CreateNoteResponseSchema, { note }); + } + + /** + * @param {ToolRequestContext} _ctx + * @param {SearchNotesRequest} request + * @returns {SearchNotesResponse} + */ + searchNotes(_ctx, request) { + const query = request.query?.toLocaleLowerCase() ?? ""; + const requiredTags = new Set(request.tags); + const limit = request.limit ?? 10; + /** @type {Note[]} */ + const notes = []; + + for (const note of this.notes.values()) { + const haystack = `${note.title}\n${note.body}`.toLocaleLowerCase(); + if (query !== "" && !haystack.includes(query)) { + continue; + } + if (requiredTags.size > 0 && ![...requiredTags].every((tag) => note.tags.includes(tag))) { + continue; + } + notes.push(note); + if (notes.length >= limit) { + break; + } + } + + return create(SearchNotesResponseSchema, { notes }); + } + + /** + * @param {ToolRequestContext} _ctx + * @param {HealthRequest} _request + * @returns {HealthResponse} + */ + health(_ctx, _request) { + return create(HealthResponseSchema, { + ok: true, + noteCount: this.notes.size, + }); + } +} + +const server = new Server( + { name: "standalone-javascript-notebook", version: "0.1.0" }, + { capabilities: { tools: {} } }, +); +const notebook = new Notebook(); +registerNotebookAPITools(server, notebook, "notebook"); + +await server.connect(new StdioServerTransport()); diff --git a/examples/9_javascript_standalone/tsconfig.json b/examples/9_javascript_standalone/tsconfig.json new file mode 100644 index 0000000..f79e9a1 --- /dev/null +++ b/examples/9_javascript_standalone/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "allowJs": true, + "checkJs": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.js"] +} diff --git a/examples/Makefile b/examples/Makefile index 843c1d3..bf543f0 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -1,21 +1,24 @@ -.PHONY: clean-jvm-build-artifacts generate lint +.PHONY: clean-build-artifacts generate lint -clean-jvm-build-artifacts: +clean-build-artifacts: rm -rf jvm/*/build 6_java_standalone/build 7_kotlin_standalone/build + rm -rf 8_typescript_standalone/dist 9_javascript_standalone/node_modules generate: - $(MAKE) clean-jvm-build-artifacts + $(MAKE) clean-build-artifacts easyp --cfg easyp.yaml mod download easyp --cfg easyp.yaml generate -p . -r . rm -rf google $(MAKE) -C 5_python_standalone generate $(MAKE) -C 6_java_standalone generate $(MAKE) -C 7_kotlin_standalone generate + $(MAKE) -C 8_typescript_standalone generate lint: - $(MAKE) clean-jvm-build-artifacts + $(MAKE) clean-build-artifacts easyp --cfg easyp.yaml mod download easyp --cfg easyp.yaml lint -p . -r . $(MAKE) -C 5_python_standalone lint $(MAKE) -C 6_java_standalone lint $(MAKE) -C 7_kotlin_standalone lint + $(MAKE) -C 8_typescript_standalone lint diff --git a/examples/README.md b/examples/README.md index fc4a307..8beef44 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,12 +2,14 @@ This directory contains standalone, runnable examples of how to build and expose Model Context Protocol (MCP) tools using Protocol Buffers with the Go, -Python, Kotlin, and Java SDKs. +Python, Kotlin, Java, TypeScript, and JavaScript SDK paths. ## Prerequisites - **easyp**: Ensure you have [easyp](https://github.com/easyp-tech/easyp) installed for code generation. - **JDK 17+ and Gradle 9.2+**: Required for the standalone Java/Kotlin projects and the dedicated JVM workspace under [`examples/jvm`](./jvm/README.md). +- **Node.js and npm**: Required for the standalone TypeScript project, + JavaScript consumption proof, and Node compile gates. ## Running the Examples 1. Generate the example artifacts from the `.proto` definitions. @@ -24,8 +26,8 @@ Python, Kotlin, and Java SDKs. through the `deps` entry in `examples/easyp.yaml` and pinned by `examples/easyp.lock`, so the examples do not depend on the local repository-root options package during generation. - `5_python_standalone`, `6_java_standalone`, and - `7_kotlin_standalone` each have their own `easyp.yaml` and lockfile to + `5_python_standalone`, `6_java_standalone`, `7_kotlin_standalone`, and + `8_typescript_standalone` each have their own `easyp.yaml` and lockfile to model user-owned projects. 2. Run any of the Go servers (e.g. `1_helloworld`): ```bash @@ -55,6 +57,18 @@ Python, Kotlin, and Java SDKs. Follow [`examples/jvm/README.md`](./jvm/README.md) for the tested `compileJava` / `compileKotlin`, `installDist`, and installed-script stdio flow. +7. Build or run the standalone TypeScript project: + ```bash + cd examples/8_typescript_standalone + make build + make run + ``` +8. Build or run the standalone JavaScript consumption proof: + ```bash + cd examples/9_javascript_standalone + make build + make run + ``` When you inspect tools in clients like `@modelcontextprotocol/inspector`, remember that omitted tool-annotation hints are often rendered with @@ -117,6 +131,28 @@ protobuf contract, and handwritten stdio server. - **No Go proto metadata**: JVM request preparation synthesizes internal protogen metadata, so the user-authored proto does not need `go_package`. +### [8_typescript_standalone](./8_typescript_standalone) (TypeScript User Project) +A standalone TypeScript MCP server with its own `package.json`, `tsconfig.json`, +`easyp.yaml`, protobuf contract, generated TypeScript sources, and handwritten +stdio server. +- **Protobuf-ES generation**: `easyp.yaml` runs `@bufbuild/protoc-gen-es` with + `target=ts` and `import_extension=js`. +- **Generated MCP sidecar**: `protoc-gen-mcp` runs with `lang=typescript` and + emits `src/generated/proto/notebook_mcp.ts`. +- **Official SDK runtime**: the server uses `Server`, `StdioServerTransport`, + and generated `registerNotebookAPITools(...)`. + +### [9_javascript_standalone](./9_javascript_standalone) (JavaScript Consumption Proof) +A plain JavaScript MCP server that consumes compiled `.js` and `.d.ts` output +from the TypeScript standalone project. +- **No direct JavaScript renderer**: this project does not use + `lang=javascript`; it proves JavaScript consumers can use compiled + TypeScript target output. +- **Editor/type metadata**: `src/server.js` uses `// @ts-check` and JSDoc + imports against generated declarations from `8_typescript_standalone/dist`. +- **Runtime proof**: the server starts over stdio and registers generated + notebook tools from compiled output. + ### [jvm](./jvm/README.md) (Java & Kotlin Official SDK Workspace) An isolated Gradle workspace that generates and runs Java and Kotlin MCP servers against the official JVM SDKs. diff --git a/examples/node_stdio_test.go b/examples/node_stdio_test.go index b32a670..5bdaedc 100644 --- a/examples/node_stdio_test.go +++ b/examples/node_stdio_test.go @@ -31,6 +31,28 @@ func TestStandaloneTypeScriptExampleOverStdio(t *testing.T) { runStandaloneNodeNotebookExample(t, session, "Ship TypeScript support", "typescript") } +func TestStandaloneJavaScriptExampleOverStdio(t *testing.T) { + root := repoRoot(t) + projectDir := filepath.Join(root, "examples/9_javascript_standalone") + buildStandaloneNodeExample(t, projectDir) + + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{ + Name: "protoc-gen-mcp-standalone-javascript-example-test-client", + Version: "v0.0.1", + }, nil) + + session, err := client.Connect(ctx, &mcp.CommandTransport{ + Command: standaloneNodeExampleCommand(projectDir, "src/server.js"), + }, nil) + if err != nil { + t.Fatalf("client.Connect() over stdio failed: %v", err) + } + defer session.Close() + + runStandaloneNodeNotebookExample(t, session, "Ship JavaScript consumption", "javascript") +} + func buildStandaloneNodeExample(t *testing.T, projectDir string) { t.Helper() From 1ea9154b259dec631d08c7275aa9a1022c447d82 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 20:14:11 +0300 Subject: [PATCH 55/74] fix(11-01): refresh standalone TypeScript protobuf output --- .../8_typescript_standalone/src/generated/proto/notebook_pb.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts index 56b539a..47bd3ba 100644 --- a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts +++ b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file proto/notebook.proto. */ export const file_proto_notebook: GenFile = /*@__PURE__*/ - fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsL8ACAQogU2VydmVyLWFzc2lnbmVkIG5vdGUgaWRlbnRpZmllci4aCAoGbm90ZS0xElQKBXRpdGxlGAIgASgJQkXatyxBGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0CiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLmABaFASdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuElEKBHRhZ3MYBCADKAlCQ9q3LD8KIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLoACARoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3ASWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAaDAoKMjAyNi0wNS0zMAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuWgRkYXRlSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCjAAgEKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEEaGQoXU2hpcCBUeXBlU2NyaXB0IHN1cHBvcnQKIFNob3J0IGh1bWFuLXJlYWRhYmxlIG5vdGUgdGl0bGUuYAFoUBJ3CgRib2R5GAIgASgJQmnatyxlYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuChhOb3RlIGJvZHkgaW4gcGxhaW4gdGV4dC4SUQoEdGFncxgDIAMoCUJD2rcsPwohT3B0aW9uYWwgdGFncyB1c2VkIGZvciBmaWx0ZXJpbmcugAIBGhcqFQoMCgp0eXBlc2NyaXB0CgUKA21jcBJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuWgRkYXRlGgwKCjIwMjYtMDUtMzBIAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVgpET3B0aW9uYWwgY2FzZS1pbnNlbnNpdGl2ZSB0ZXh0IHF1ZXJ5IG1hdGNoZWQgYWdhaW5zdCB0aXRsZSBhbmQgYm9keS5gARoMCgp0eXBlc2NyaXB0SACIAQESVgoEdGFncxgCIAMoCUJI2rcsRAo0T3B0aW9uYWwgdGFnczsgYSBub3RlIG11c3QgaGF2ZSBldmVyeSByZXF1ZXN0ZWQgdGFnLoACARoJKgcKBQoDbWNwElQKBWxpbWl0GAMgASgFQkDatyw8oQEAAAAAAADwP6kBAAAAAAAASUAiAjgKCiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uSAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJ8ACAQoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LjLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMSC0NyZWF0ZSBub3RlGi5DcmVhdGUgYSBub3RlIGluIHRoZSBsb2NhbCBpbi1tZW1vcnkgbm90ZWJvb2suUgQQACAAEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0EgxTZWFyY2ggbm90ZXMaHlNlYXJjaCBub3RlcyBieSB0ZXh0IGFuZCB0YWdzLlIECAEgABKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhIPTm90ZWJvb2sgaGVhbHRoGi1WZXJpZnkgdGhhdCB0aGUgbm90ZWJvb2sgTUNQIHNlcnZlciBpcyBhbGl2ZS5SBAgBIAAaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); + fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsL8ACAQogU2VydmVyLWFzc2lnbmVkIG5vdGUgaWRlbnRpZmllci4aCAoGbm90ZS0xElQKBXRpdGxlGAIgASgJQkXatyxBGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0CiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLmABaFASdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuElEKBHRhZ3MYBCADKAlCQ9q3LD8KIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLoACARoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3ASWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAKKk9wdGlvbmFsIGR1ZSBkYXRlIGluIElTTyA4NjAxIGRhdGUgZm9ybWF0LloEZGF0ZRoMCgoyMDI2LTA1LTMwSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCgKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuwAIBQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEFoUBoZChdTaGlwIFR5cGVTY3JpcHQgc3VwcG9ydAogU2hvcnQgaHVtYW4tcmVhZGFibGUgbm90ZSB0aXRsZS5gARJ3CgRib2R5GAIgASgJQmnatyxlChhOb3RlIGJvZHkgaW4gcGxhaW4gdGV4dC5gAWjQDxpECkJWZXJpZnkgdGhhdCBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MgYXJlIHBsZWFzYW50IHRvIHVzZS4SUQoEdGFncxgDIAMoCUJD2rcsPwohT3B0aW9uYWwgdGFncyB1c2VkIGZvciBmaWx0ZXJpbmcugAIBGhcqFQoMCgp0eXBlc2NyaXB0CgUKA21jcBJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQFoEZGF0ZRoMCgoyMDI2LTA1LTMwCipPcHRpb25hbCBkdWUgZGF0ZSBpbiBJU08gODYwMSBkYXRlIGZvcm1hdC5IAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVhoMCgp0eXBlc2NyaXB0CkRPcHRpb25hbCBjYXNlLWluc2Vuc2l0aXZlIHRleHQgcXVlcnkgbWF0Y2hlZCBhZ2FpbnN0IHRpdGxlIGFuZCBib2R5LmABSACIAQESVgoEdGFncxgCIAMoCUJI2rcsRAo0T3B0aW9uYWwgdGFnczsgYSBub3RlIG11c3QgaGF2ZSBldmVyeSByZXF1ZXN0ZWQgdGFnLoACARoJKgcKBQoDbWNwElQKBWxpbWl0GAMgASgFQkDatyw8CiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uoQEAAAAAAADwP6kBAAAAAAAASUAiAjgKSAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJ8ACAQoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LjLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMaLkNyZWF0ZSBhIG5vdGUgaW4gdGhlIGxvY2FsIGluLW1lbW9yeSBub3RlYm9vay5SBCAAEAASC0NyZWF0ZSBub3RlEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0Gh5TZWFyY2ggbm90ZXMgYnkgdGV4dCBhbmQgdGFncy5SBAgBIAASDFNlYXJjaCBub3RlcxKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhIPTm90ZWJvb2sgaGVhbHRoGi1WZXJpZnkgdGhhdCB0aGUgbm90ZWJvb2sgTUNQIHNlcnZlciBpcyBhbGl2ZS5SBAgBIAAaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); /** * @generated from message standalone.notebook.v1.Note From 9961e4df70cb351c0efc71df839149f61ed22f4f Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 20:14:21 +0300 Subject: [PATCH 56/74] docs(12-01): document TypeScript rollout --- AGENTS.md | 20 ++++--- CHANGELOG.md | 28 +++++++++ README.md | 160 +++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 183 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 99488be..098ca33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,8 +122,8 @@ architecture unless explicitly revised. - Python generation emits package `__init__.py` files next to generated `*_mcp.py` modules so generated directories can be imported as Python packages -- `testdata/golden`: golden snapshots for generated Go, Python, Kotlin, and - Java binding files +- `testdata/golden`: golden snapshots for generated Go, Python, Kotlin, Java, + and TypeScript binding files - `testdata/unsupported`: negative fixtures for fail-fast generator coverage ## MVP Rules @@ -157,6 +157,9 @@ architecture unless explicitly revised. - `mcp.options.v1` must declare its `go_package` directly in `mcp/options/v1/options.proto` so downstream Easyp consumers do not need a special `go_package_prefix` override +- Direct lang=javascript generation is deferred in v1.1; JavaScript support + is through compiled TypeScript target `.js` output plus generated `.d.ts` + declarations. ## Public API @@ -274,9 +277,9 @@ architecture unless explicitly revised. current-file types actually referenced by other generated files, so cross-file imports work without forcing unrelated hidden-only types into the public API surface - - dedicated `examples/` directory featuring 7 standalone integration + - dedicated `examples/` directory featuring 9 standalone integration projects spanning quickstarts to complex CRM mocks and pure Python, Java, - and Kotlin user-style projects + Kotlin, TypeScript, and JavaScript user-style projects - support for `oneof` explicit requiredness through `mcp.options.v1.oneof` options - strict schema generation correctly differentiating zero-values (`0`, `0.0`, `""`) using pointer constraints - runtime registration and JSON Schema validation in `mcpruntime` @@ -331,10 +334,11 @@ architecture unless explicitly revised. `examples/8_typescript_standalone` and `examples/9_javascript_standalone`, covering user-style `lang=typescript` generation with Protobuf-ES plus plain JavaScript consumption of compiled generated `.js` and `.d.ts` output -- repository docs now route JVM users through `examples/jvm/README.md`, and - rollout messaging states that releases publish the `protoc-gen-mcp binary` - while downstream JVM users compile generated sources against the official SDK - artifacts +- repository docs now route JVM users through `examples/jvm/README.md` and + Node users through `examples/8_typescript_standalone` plus + `examples/9_javascript_standalone`; rollout messaging states that releases + publish the `protoc-gen-mcp binary` while downstream JVM and Node users + compile generated sources against official SDK artifacts and npm dependencies - `example_DescribeAdvancedShapes` covers the full current advanced contract matrix in the test server: maps, timestamps, durations, field masks, `Struct`/`Value`/`ListValue`, `Any`, scalar wrappers, raw float ProtoJSON, diff --git a/CHANGELOG.md b/CHANGELOG.md index bfbb492..e265588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- TypeScript MCP sidecar generation through `lang=typescript`, targeting + `@modelcontextprotocol/sdk`, Protobuf-ES `_pb.ts` output, NodeNext `.js` + imports, and Ajv-backed raw JSON Schema validation. +- Generated TypeScript runtime wiring for low-level `tools/list` and + `tools/call` handlers, typed `ToolHandler` interfaces, + namespace-aware `registerTools(server, impl, namespace?)`, ProtoJSON + conversion, and structured output validation. +- Standalone Node examples: + `examples/8_typescript_standalone` for a user-style TypeScript project and + `examples/9_javascript_standalone` for JavaScript consumption of compiled + TypeScript target `.js` plus `.d.ts` output. +- Node verification gates covering local npm compile/build checks, generated + Node stdio behavior, and standalone TypeScript/JavaScript stdio parity. + +### Changed + +- Documentation now explains that direct `lang=javascript` generation is + deferred; JavaScript users consume compiled TypeScript target output. +- Release messaging now states that tagged releases publish the + `protoc-gen-mcp` binary, while downstream Node projects compile generated + TypeScript against npm dependencies rather than using repository-published + Node runtime artifacts. + ## [0.3.0] - 2026-03-29 ### Changed @@ -58,6 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial commit. +[Unreleased]: https://github.com/easyp-tech/protoc-gen-mcp/compare/v0.3.0...HEAD [0.3.0]: https://github.com/easyp-tech/protoc-gen-mcp/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/easyp-tech/protoc-gen-mcp/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/easyp-tech/protoc-gen-mcp/compare/v0.1.0...v0.1.1 diff --git a/README.md b/README.md index 7510473..c6635da 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,20 @@ # protoc-gen-mcp -`protoc-gen-mcp` generates Go, Python, Kotlin, and Java MCP tool bindings -from protobuf services. +`protoc-gen-mcp` generates Go, Python, Kotlin, Java, and TypeScript MCP tool +bindings from protobuf services. JavaScript users consume the compiled +TypeScript output and declarations. ## MVP - protobuf is the source of truth -- generator emits typed Go, Python, Kotlin, and Java MCP bindings +- generator emits typed Go, Python, Kotlin, Java, and TypeScript MCP bindings - Go runtime uses the official Go MCP SDK - Python runtime targets the official MCP Python SDK with `google.protobuf` - Kotlin runtime targets the official `io.modelcontextprotocol:kotlin-sdk-server` SDK - Java runtime targets the official `io.modelcontextprotocol.sdk:mcp` SDK +- TypeScript runtime targets the official `@modelcontextprotocol/sdk` SDK, + Protobuf-ES, and Ajv-backed raw JSON Schema validation - generated Python handlers use dataclasses and explicit `oneof` wrapper types from `*_mcp.py`; `*_pb2.py` stays an internal runtime dependency - generated Kotlin handlers implement `ToolHandler` and are registered @@ -19,6 +22,10 @@ from protobuf services. - generated Java handlers implement nested `ToolHandler` interfaces inside a generated `Mcp` sidecar and are registered through `registerTools(McpServerTransportProvider transportProvider, ToolHandler impl, String namespace)` +- generated TypeScript handlers implement `ToolHandler` and are + registered through `registerTools(server, impl, namespace?)` +- Direct lang=javascript generation is intentionally deferred; JavaScript + projects use compiled TypeScript target `.js` files plus `.d.ts` declarations - request and response JSON follows ProtoJSON rules - runtime validation is driven by generated JSON Schema @@ -35,6 +42,10 @@ easyp --cfg easyp.test.yaml generate -p internal/testproto -r . gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist go test ./internal/examplemcp -run 'Test(Java|Kotlin).*OverStdio' -count=1 +(cd examples/node/sdk-spike && npm ci && npm run typecheck && npm run build) +(cd examples/8_typescript_standalone && make lint && make clean build) +(cd examples/9_javascript_standalone && make clean build) +go test ./examples -run 'TestStandalone(TypeScript|JavaScript)ExampleOverStdio' -count=1 go test ./... goreleaser check ``` @@ -48,12 +59,44 @@ the MCP options package. CI is implemented in [tests.yml](.github/workflows/tests.yml) and runs config validation, Easyp lint, Easyp generation, a generated-file -freshness check, JVM compile/install gates, JVM stdio parity checks, and -`go test ./...`. Releases are implemented in +freshness check, JVM compile/install gates, JVM stdio parity checks, Node +compile/build gates, generated Node stdio checks through `go test ./...`, and +standalone Node example tests. Releases are implemented in [release.yml](.github/workflows/release.yml) and use [`.goreleaser.yaml`](.goreleaser.yaml) to publish tagged builds of the `protoc-gen-mcp` binary. This repository does -not publish separate Java or Kotlin runtime artifacts. +not publish separate Java, Kotlin, TypeScript, or JavaScript runtime artifacts; +downstream projects compile generated source against their language SDK +dependencies. + +## TypeScript And JavaScript Support + +TypeScript support is implemented for Node stdio servers and verified through +the official MCP TypeScript SDK, Protobuf-ES, and Ajv. User-owned standalone +project layouts are shown in +[`examples/8_typescript_standalone`](examples/8_typescript_standalone/) and +[`examples/9_javascript_standalone`](examples/9_javascript_standalone/). + +- Node prerequisites for the in-repo walkthrough are Go 1.24+, Node.js, npm, + `easyp v0.15.2-rc1`, and the npm dependencies pinned by the examples. +- The Node generator mode is `lang=typescript`. +- The tested stack is `@modelcontextprotocol/sdk@1.29.0`, + `@bufbuild/protobuf@2.12.0`, `@bufbuild/protoc-gen-es`, and `ajv@8.20.0`. +- Protobuf message classes are generated by Protobuf-ES as `_pb.ts` files with + NodeNext-compatible `.js` import specifiers. +- `protoc-gen-mcp` generates `*_mcp.ts` sidecars that install low-level + `tools/list` and `tools/call` handlers on the official SDK `Server`. +- Generated TypeScript preserves the shared raw JSON Schema and compiles it + with Ajv, then maps request/response payloads through Protobuf-ES ProtoJSON. +- JavaScript consumption uses the compiled `.js` output and generated `.d.ts` + declarations from the TypeScript target. Direct lang=javascript generation + is intentionally deferred until compiled TypeScript output proves + insufficient for common JavaScript projects. + +Use the standalone TypeScript example when you want a full user-project +generation flow. Use the JavaScript example when you want to inspect how a +plain `.js` server imports compiled generated output without a separate +renderer. ## JVM Support @@ -131,6 +174,17 @@ npx -y @modelcontextprotocol/inspector ./examples/jvm/java-server/build/install/ npx -y @modelcontextprotocol/inspector ./examples/jvm/kotlin-server/build/install/kotlin-server/bin/kotlin-server ``` +For standalone Node examples, build first and then point the Inspector at the +Node server entrypoint: + +```bash +(cd examples/8_typescript_standalone && make build) +npx -y @modelcontextprotocol/inspector node ./examples/8_typescript_standalone/dist/server.js + +(cd examples/9_javascript_standalone && make build) +npx -y @modelcontextprotocol/inspector node ./examples/9_javascript_standalone/src/server.js +``` + When the Inspector UI opens, connect to the server, open the Tools tab, run List Tools, then call a generated tool such as `example_Health` or `example_CreateReport`. This is the fastest manual check that generated tool @@ -141,7 +195,8 @@ stdio wiring are visible to an MCP client. We provide several standalone, runnable examples demonstrating generated MCP tools, protobuf `options`, validation constraints, and integration with the -official Go, Python, Kotlin, and Java SDKs. Check out the +official Go, Python, Kotlin, Java, TypeScript, and JavaScript SDK paths. Check +out the [examples/](examples/) directory for: - [1_helloworld](examples/1_helloworld/) - Minimal Quickstart setup. - [2_weather_api](examples/2_weather_api/) - Read-only queries, validation limits, Oneofs. @@ -150,6 +205,8 @@ official Go, Python, Kotlin, and Java SDKs. Check out the - [5_python_standalone](examples/5_python_standalone/) - A Python-only user-style project with its own `pyproject.toml`, `easyp.yaml`, generated bindings, and stdio server. - [6_java_standalone](examples/6_java_standalone/) - A Java user-style project with its own Gradle build, `easyp.yaml`, protobuf contract, and generated MCP sidecar. - [7_kotlin_standalone](examples/7_kotlin_standalone/) - A Kotlin user-style project with its own Gradle build, `easyp.yaml`, protobuf contract, and generated MCP sidecar. +- [8_typescript_standalone](examples/8_typescript_standalone/) - A TypeScript user-style project with its own npm package, `tsconfig.json`, `easyp.yaml`, Protobuf-ES output, generated MCP sidecar, and stdio server. +- [9_javascript_standalone](examples/9_javascript_standalone/) - A JavaScript consumption proof that imports compiled generated `.js` output and `.d.ts` metadata from the TypeScript standalone project. - [jvm](examples/jvm/README.md) - A Java/Kotlin official SDK workspace with Gradle-managed protobuf generation, `lang=java` / `lang=kotlin` sidecars, `installDist` scripts, and stdio verification. @@ -173,8 +230,8 @@ policy, ProtoJSON contract, and common patterns. ## Generation With Easyp The intended workflow is `easyp`, not manual `protoc` invocation. For mixed -Go/Python/JVM projects, `easyp` can drive `protoc-gen-go`, the standard Python -protobuf generator, and `protoc-gen-mcp` from one config. +Go/Python/JVM/Node projects, `easyp` can drive `protoc-gen-go`, the standard +Python protobuf generator, Protobuf-ES, and `protoc-gen-mcp` from one config. Before running generation, make sure `protoc-gen-go` is installed and available in `PATH` if your config uses the standard Go plugin: @@ -226,6 +283,17 @@ generate: opts: paths: source_relative lang: kotlin + - command: ["./node_modules/.bin/protoc-gen-es"] + with_imports: true + out: src/generated + opts: + target: ts + import_extension: js + - command: ["go", "run", "github.com/easyp-tech/protoc-gen-mcp/cmd/protoc-gen-mcp@latest"] + out: src/generated + opts: + paths: source_relative + lang: typescript ``` Typical commands: @@ -236,14 +304,15 @@ easyp --cfg easyp.yaml lint -p mcp -r . easyp --cfg easyp.yaml generate -p mcp -r . ``` -That generates `*.pb.go`, `*_pb2.py`, `*.mcp.go`, `*_mcp.py`, and package -`__init__.py` files next to the source `.proto` files. The Python target -currently supports only `python_runtime=google.protobuf`. Generated Python -output also emits a small `mcp/__init__.py` bridge so `mcp.options.*` protobuf -modules can coexist with the official `mcp` SDK package in one import tree. No -special Easyp override is required for `mcp.options.v1`, because the package -declares `go_package` directly in `options.proto`. For reproducible builds, -prefer pinning a specific tag instead of `@latest`. +That generates language-specific protobuf output plus MCP sidecars such as +`*.pb.go`, `*_pb2.py`, `*.mcp.go`, `*_mcp.py`, Protobuf-ES `_pb.ts`, and +TypeScript `*_mcp.ts` files. The Python target currently supports only +`python_runtime=google.protobuf`. Generated Python output also emits a small +`mcp/__init__.py` bridge so `mcp.options.*` protobuf modules can coexist with +the official `mcp` SDK package in one import tree. No special Easyp override is +required for `mcp.options.v1`, because the package declares `go_package` +directly in `options.proto`. For reproducible builds, prefer pinning a specific +tag instead of `@latest`. For JVM consumers, the language selectors are `lang=java` and `lang=kotlin`. The Java path generates a Java sidecar alongside protobuf Java output. The @@ -255,6 +324,23 @@ Go `go_package` option just to satisfy `protoc-gen-mcp`. See [examples/7_kotlin_standalone](examples/7_kotlin_standalone/), and [examples/jvm/README.md](examples/jvm/README.md) for runnable layouts. +For TypeScript consumers, generate Protobuf-ES `_pb.ts` files first and then run +`protoc-gen-mcp` with `lang=typescript` into the same generated-source tree. +Use `import_extension=js` so emitted imports work under Node ESM and +`moduleResolution: NodeNext`. The generated MCP sidecar imports the official +`@modelcontextprotocol/sdk` low-level `Server`, preserves raw JSON Schemas, +validates them through Ajv, and maps payloads through Protobuf-ES ProtoJSON. +See [examples/8_typescript_standalone](examples/8_typescript_standalone/) for a +complete standalone project with pinned npm dependencies and checked-in +generated sources. + +For JavaScript consumers, do not use `lang=javascript`; direct +lang=javascript generation is intentionally deferred. Compile the TypeScript +target and import the emitted `.js` files from JavaScript. The generated `.d.ts` +files provide editor and `// @ts-check` metadata. See +[examples/9_javascript_standalone](examples/9_javascript_standalone/) for the +tested consumption path. + For Python-only projects, omit the Go plugins and keep only the standard `python` plugin plus `protoc-gen-mcp` with `lang=python`. User-authored Python protos do not need a `go_package` option just to satisfy the generator; the @@ -657,6 +743,46 @@ For a service like the example above: - `Health` is exposed as tool `weather_Health` - `InternalDebug` is omitted because `hidden = true` +The TypeScript target follows the same public shape with Protobuf-ES message +types: + +```ts +import { create } from "@bufbuild/protobuf"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + ForecastResponseSchema, + HealthResponseSchema, +} from "./gen/weather/v1/weather_pb.js"; +import { + type WeatherAPIToolHandler, + registerWeatherAPITools, +} from "./gen/weather/v1/weather_mcp.js"; + +const handler: WeatherAPIToolHandler = { + async forecast(_ctx, req) { + return create(ForecastResponseSchema, { + reportId: "forecast-1", + details: req.details, + }); + }, + async health() { + return create(HealthResponseSchema); + }, +}; + +const server = new Server( + { name: "weather-mcp", version: "0.1.0" }, + { capabilities: { tools: {} } }, +); +registerWeatherAPITools(server, handler, "weather"); +await server.connect(new StdioServerTransport()); +``` + +JavaScript projects import the compiled `weather_mcp.js` and `weather_pb.js` +files from the TypeScript build output instead of generating a separate +JavaScript sidecar. + ## Status This repository currently implements the MVP only: From 57863b89a463983150d20caea1635a0bda0f596c Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 21:16:05 +0300 Subject: [PATCH 57/74] fix(11-01): refresh TypeScript standalone descriptor --- .../8_typescript_standalone/src/generated/proto/notebook_pb.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts index 47bd3ba..eec4396 100644 --- a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts +++ b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file proto/notebook.proto. */ export const file_proto_notebook: GenFile = /*@__PURE__*/ - fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsL8ACAQogU2VydmVyLWFzc2lnbmVkIG5vdGUgaWRlbnRpZmllci4aCAoGbm90ZS0xElQKBXRpdGxlGAIgASgJQkXatyxBGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0CiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLmABaFASdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuElEKBHRhZ3MYBCADKAlCQ9q3LD8KIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLoACARoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3ASWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAKKk9wdGlvbmFsIGR1ZSBkYXRlIGluIElTTyA4NjAxIGRhdGUgZm9ybWF0LloEZGF0ZRoMCgoyMDI2LTA1LTMwSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCgKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuwAIBQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEFoUBoZChdTaGlwIFR5cGVTY3JpcHQgc3VwcG9ydAogU2hvcnQgaHVtYW4tcmVhZGFibGUgbm90ZSB0aXRsZS5gARJ3CgRib2R5GAIgASgJQmnatyxlChhOb3RlIGJvZHkgaW4gcGxhaW4gdGV4dC5gAWjQDxpECkJWZXJpZnkgdGhhdCBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MgYXJlIHBsZWFzYW50IHRvIHVzZS4SUQoEdGFncxgDIAMoCUJD2rcsPwohT3B0aW9uYWwgdGFncyB1c2VkIGZvciBmaWx0ZXJpbmcugAIBGhcqFQoMCgp0eXBlc2NyaXB0CgUKA21jcBJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQFoEZGF0ZRoMCgoyMDI2LTA1LTMwCipPcHRpb25hbCBkdWUgZGF0ZSBpbiBJU08gODYwMSBkYXRlIGZvcm1hdC5IAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVhoMCgp0eXBlc2NyaXB0CkRPcHRpb25hbCBjYXNlLWluc2Vuc2l0aXZlIHRleHQgcXVlcnkgbWF0Y2hlZCBhZ2FpbnN0IHRpdGxlIGFuZCBib2R5LmABSACIAQESVgoEdGFncxgCIAMoCUJI2rcsRAo0T3B0aW9uYWwgdGFnczsgYSBub3RlIG11c3QgaGF2ZSBldmVyeSByZXF1ZXN0ZWQgdGFnLoACARoJKgcKBQoDbWNwElQKBWxpbWl0GAMgASgFQkDatyw8CiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uoQEAAAAAAADwP6kBAAAAAAAASUAiAjgKSAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJ8ACAQoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LjLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMaLkNyZWF0ZSBhIG5vdGUgaW4gdGhlIGxvY2FsIGluLW1lbW9yeSBub3RlYm9vay5SBCAAEAASC0NyZWF0ZSBub3RlEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0Gh5TZWFyY2ggbm90ZXMgYnkgdGV4dCBhbmQgdGFncy5SBAgBIAASDFNlYXJjaCBub3RlcxKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhIPTm90ZWJvb2sgaGVhbHRoGi1WZXJpZnkgdGhhdCB0aGUgbm90ZWJvb2sgTUNQIHNlcnZlciBpcyBhbGl2ZS5SBAgBIAAaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); + fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsLxoICgZub3RlLTHAAgEKIFNlcnZlci1hc3NpZ25lZCBub3RlIGlkZW50aWZpZXIuElQKBXRpdGxlGAIgASgJQkXatyxBYAFoUBoZChdTaGlwIFR5cGVTY3JpcHQgc3VwcG9ydAogU2hvcnQgaHVtYW4tcmVhZGFibGUgbm90ZSB0aXRsZS4SdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuElEKBHRhZ3MYBCADKAlCQ9q3LD8aFyoVCgwKCnR5cGVzY3JpcHQKBQoDbWNwCiFPcHRpb25hbCB0YWdzIHVzZWQgZm9yIGZpbHRlcmluZy6AAgESWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAKKk9wdGlvbmFsIGR1ZSBkYXRlIGluIElTTyA4NjAxIGRhdGUgZm9ybWF0LloEZGF0ZRoMCgoyMDI2LTA1LTMwSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCjAAgEKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEFoUBoZChdTaGlwIFR5cGVTY3JpcHQgc3VwcG9ydAogU2hvcnQgaHVtYW4tcmVhZGFibGUgbm90ZSB0aXRsZS5gARJ3CgRib2R5GAIgASgJQmnatyxlGkQKQlZlcmlmeSB0aGF0IGdlbmVyYXRlZCBUeXBlU2NyaXB0IE1DUCBiaW5kaW5ncyBhcmUgcGxlYXNhbnQgdG8gdXNlLgoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8SUQoEdGFncxgDIAMoCUJD2rcsP4ACARoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3AKIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLhJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuWgRkYXRlGgwKCjIwMjYtMDUtMzBIAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVgpET3B0aW9uYWwgY2FzZS1pbnNlbnNpdGl2ZSB0ZXh0IHF1ZXJ5IG1hdGNoZWQgYWdhaW5zdCB0aXRsZSBhbmQgYm9keS5gARoMCgp0eXBlc2NyaXB0SACIAQESVgoEdGFncxgCIAMoCUJI2rcsRBoJKgcKBQoDbWNwCjRPcHRpb25hbCB0YWdzOyBhIG5vdGUgbXVzdCBoYXZlIGV2ZXJ5IHJlcXVlc3RlZCB0YWcugAIBElQKBWxpbWl0GAMgASgFQkDatyw8oQEAAAAAAADwP6kBAAAAAAAASUAiAjgKCiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uSAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJ8ACAQoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LjLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMSC0NyZWF0ZSBub3RlGi5DcmVhdGUgYSBub3RlIGluIHRoZSBsb2NhbCBpbi1tZW1vcnkgbm90ZWJvb2suUgQQACAAEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0EgxTZWFyY2ggbm90ZXMaHlNlYXJjaCBub3RlcyBieSB0ZXh0IGFuZCB0YWdzLlIECAEgABKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhotVmVyaWZ5IHRoYXQgdGhlIG5vdGVib29rIE1DUCBzZXJ2ZXIgaXMgYWxpdmUuUgQgAAgBEg9Ob3RlYm9vayBoZWFsdGgaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); /** * @generated from message standalone.notebook.v1.Note From e921bd1198c7b76825ac0e27c2eb575069050100 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 7 May 2026 21:23:29 +0300 Subject: [PATCH 58/74] fix(11-01): stabilize TypeScript standalone generation --- AGENTS.md | 3 + examples/8_typescript_standalone/Makefile | 1 + .../src/generated/proto/notebook_pb.ts | 2 +- internal/devtools/protobufesnormalize/main.go | 82 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 internal/devtools/protobufesnormalize/main.go diff --git a/AGENTS.md b/AGENTS.md index 098ca33..f63ef14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,9 @@ architecture unless explicitly revised. - `internal/codegen/typescript_*_test.go`: TypeScript semantic model, naming, renderer contract, golden, runtime, stdio, and strict NodeNext compile-gate tests +- `internal/devtools/protobufesnormalize`: repository-local normalizer for + Protobuf-ES `fileDesc(...)` descriptor strings generated in standalone + TypeScript examples so checked-in output remains deterministic - `internal/examplemcp`: reusable example MCP server wiring and stdio smoke test - `internal/pythontest`: hermetic Python test runtime bootstrap used by Go tests that execute generated Python code diff --git a/examples/8_typescript_standalone/Makefile b/examples/8_typescript_standalone/Makefile index 7f8b72f..0d5042a 100644 --- a/examples/8_typescript_standalone/Makefile +++ b/examples/8_typescript_standalone/Makefile @@ -5,6 +5,7 @@ setup: generate: setup npm run generate + go run ../../internal/devtools/protobufesnormalize $$(find src/generated -name '*_pb.ts' -type f | sort) lint: easyp --cfg easyp.yaml mod download diff --git a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts index eec4396..7c40789 100644 --- a/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts +++ b/examples/8_typescript_standalone/src/generated/proto/notebook_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file proto/notebook.proto. */ export const file_proto_notebook: GenFile = /*@__PURE__*/ - fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsLxoICgZub3RlLTHAAgEKIFNlcnZlci1hc3NpZ25lZCBub3RlIGlkZW50aWZpZXIuElQKBXRpdGxlGAIgASgJQkXatyxBYAFoUBoZChdTaGlwIFR5cGVTY3JpcHQgc3VwcG9ydAogU2hvcnQgaHVtYW4tcmVhZGFibGUgbm90ZSB0aXRsZS4SdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuElEKBHRhZ3MYBCADKAlCQ9q3LD8aFyoVCgwKCnR5cGVzY3JpcHQKBQoDbWNwCiFPcHRpb25hbCB0YWdzIHVzZWQgZm9yIGZpbHRlcmluZy6AAgESWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAKKk9wdGlvbmFsIGR1ZSBkYXRlIGluIElTTyA4NjAxIGRhdGUgZm9ybWF0LloEZGF0ZRoMCgoyMDI2LTA1LTMwSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCjAAgEKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEFoUBoZChdTaGlwIFR5cGVTY3JpcHQgc3VwcG9ydAogU2hvcnQgaHVtYW4tcmVhZGFibGUgbm90ZSB0aXRsZS5gARJ3CgRib2R5GAIgASgJQmnatyxlGkQKQlZlcmlmeSB0aGF0IGdlbmVyYXRlZCBUeXBlU2NyaXB0IE1DUCBiaW5kaW5ncyBhcmUgcGxlYXNhbnQgdG8gdXNlLgoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuYAFo0A8SUQoEdGFncxgDIAMoCUJD2rcsP4ACARoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3AKIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLhJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuWgRkYXRlGgwKCjIwMjYtMDUtMzBIAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVgpET3B0aW9uYWwgY2FzZS1pbnNlbnNpdGl2ZSB0ZXh0IHF1ZXJ5IG1hdGNoZWQgYWdhaW5zdCB0aXRsZSBhbmQgYm9keS5gARoMCgp0eXBlc2NyaXB0SACIAQESVgoEdGFncxgCIAMoCUJI2rcsRBoJKgcKBQoDbWNwCjRPcHRpb25hbCB0YWdzOyBhIG5vdGUgbXVzdCBoYXZlIGV2ZXJ5IHJlcXVlc3RlZCB0YWcugAIBElQKBWxpbWl0GAMgASgFQkDatyw8oQEAAAAAAADwP6kBAAAAAAAASUAiAjgKCiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uSAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJ8ACAQoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LjLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMSC0NyZWF0ZSBub3RlGi5DcmVhdGUgYSBub3RlIGluIHRoZSBsb2NhbCBpbi1tZW1vcnkgbm90ZWJvb2suUgQQACAAEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0EgxTZWFyY2ggbm90ZXMaHlNlYXJjaCBub3RlcyBieSB0ZXh0IGFuZCB0YWdzLlIECAEgABKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhotVmVyaWZ5IHRoYXQgdGhlIG5vdGVib29rIE1DUCBzZXJ2ZXIgaXMgYWxpdmUuUgQgAAgBEg9Ob3RlYm9vayBoZWFsdGgaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); + fileDesc("ChRwcm90by9ub3RlYm9vay5wcm90bxIWc3RhbmRhbG9uZS5ub3RlYm9vay52MSKxBAoETm90ZRI/CgJpZBgBIAEoCUIz2rcsLwogU2VydmVyLWFzc2lnbmVkIG5vdGUgaWRlbnRpZmllci4aCAoGbm90ZS0xwAIBElQKBXRpdGxlGAIgASgJQkXatyxBCiBTaG9ydCBodW1hbi1yZWFkYWJsZSBub3RlIHRpdGxlLhoZChdTaGlwIFR5cGVTY3JpcHQgc3VwcG9ydGABaFASdwoEYm9keRgDIAEoCUJp2rcsZQoYTm90ZSBib2R5IGluIHBsYWluIHRleHQuGkQKQlZlcmlmeSB0aGF0IGdlbmVyYXRlZCBUeXBlU2NyaXB0IE1DUCBiaW5kaW5ncyBhcmUgcGxlYXNhbnQgdG8gdXNlLmABaNAPElEKBHRhZ3MYBCADKAlCQ9q3LD8KIU9wdGlvbmFsIHRhZ3MgdXNlZCBmb3IgZmlsdGVyaW5nLhoXKhUKDAoKdHlwZXNjcmlwdAoFCgNtY3CAAgESWwoIZHVlX2RhdGUYBSABKAlCRNq3LEAKKk9wdGlvbmFsIGR1ZSBkYXRlIGluIElTTyA4NjAxIGRhdGUgZm9ybWF0LhoMCgoyMDI2LTA1LTMwWgRkYXRlSACIAQESXAoKY3JlYXRlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCLNq3LCgKI1NlcnZlci1hc3NpZ25lZCBjcmVhdGlvbiB0aW1lc3RhbXAuwAIBQgsKCV9kdWVfZGF0ZSKfAwoRQ3JlYXRlTm90ZVJlcXVlc3QSVAoFdGl0bGUYASABKAlCRdq3LEEKIFNob3J0IGh1bWFuLXJlYWRhYmxlIG5vdGUgdGl0bGUuGhkKF1NoaXAgVHlwZVNjcmlwdCBzdXBwb3J0YAFoUBJ3CgRib2R5GAIgASgJQmnatyxlChhOb3RlIGJvZHkgaW4gcGxhaW4gdGV4dC4aRApCVmVyaWZ5IHRoYXQgZ2VuZXJhdGVkIFR5cGVTY3JpcHQgTUNQIGJpbmRpbmdzIGFyZSBwbGVhc2FudCB0byB1c2UuYAFo0A8SUQoEdGFncxgDIAMoCUJD2rcsPwohT3B0aW9uYWwgdGFncyB1c2VkIGZvciBmaWx0ZXJpbmcuGhcqFQoMCgp0eXBlc2NyaXB0CgUKA21jcIACARJbCghkdWVfZGF0ZRgEIAEoCUJE2rcsQAoqT3B0aW9uYWwgZHVlIGRhdGUgaW4gSVNPIDg2MDEgZGF0ZSBmb3JtYXQuGgwKCjIwMjYtMDUtMzBaBGRhdGVIAIgBAUILCglfZHVlX2RhdGUiQAoSQ3JlYXRlTm90ZVJlc3BvbnNlEioKBG5vdGUYASABKAsyHC5zdGFuZGFsb25lLm5vdGVib29rLnYxLk5vdGUixgIKElNlYXJjaE5vdGVzUmVxdWVzdBJuCgVxdWVyeRgBIAEoCUJa2rcsVgpET3B0aW9uYWwgY2FzZS1pbnNlbnNpdGl2ZSB0ZXh0IHF1ZXJ5IG1hdGNoZWQgYWdhaW5zdCB0aXRsZSBhbmQgYm9keS4aDAoKdHlwZXNjcmlwdGABSACIAQESVgoEdGFncxgCIAMoCUJI2rcsRAo0T3B0aW9uYWwgdGFnczsgYSBub3RlIG11c3QgaGF2ZSBldmVyeSByZXF1ZXN0ZWQgdGFnLhoJKgcKBQoDbWNwgAIBElQKBWxpbWl0GAMgASgFQkDatyw8CiJNYXhpbXVtIG51bWJlciBvZiBub3RlcyB0byByZXR1cm4uIgI4CqEBAAAAAAAA8D+pAQAAAAAAAElASAGIAQFCCAoGX3F1ZXJ5QggKBl9saW1pdCJCChNTZWFyY2hOb3Rlc1Jlc3BvbnNlEisKBW5vdGVzGAEgAygLMhwuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5Ob3RlIg8KDUhlYWx0aFJlcXVlc3QiXQoOSGVhbHRoUmVzcG9uc2USCgoCb2sYASABKAgSPwoKbm90ZV9jb3VudBgCIAEoBUIr2rcsJwoiQ3VycmVudCBudW1iZXIgb2Ygbm90ZXMgaW4gbWVtb3J5LsACATLgBAoLTm90ZWJvb2tBUEkSrAEKCkNyZWF0ZU5vdGUSKS5zdGFuZGFsb25lLm5vdGVib29rLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Giouc3RhbmRhbG9uZS5ub3RlYm9vay52MS5DcmVhdGVOb3RlUmVzcG9uc2UiR9K3LEMSC0NyZWF0ZSBub3RlGi5DcmVhdGUgYSBub3RlIGluIHRoZSBsb2NhbCBpbi1tZW1vcnkgbm90ZWJvb2suUgQQACAAEqABCgtTZWFyY2hOb3RlcxIqLnN0YW5kYWxvbmUubm90ZWJvb2sudjEuU2VhcmNoTm90ZXNSZXF1ZXN0Gisuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5TZWFyY2hOb3Rlc1Jlc3BvbnNlIjjStyw0EgxTZWFyY2ggbm90ZXMaHlNlYXJjaCBub3RlcyBieSB0ZXh0IGFuZCB0YWdzLlIECAEgABKjAQoGSGVhbHRoEiUuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXF1ZXN0GiYuc3RhbmRhbG9uZS5ub3RlYm9vay52MS5IZWFsdGhSZXNwb25zZSJK0rcsRhIPTm90ZWJvb2sgaGVhbHRoGi1WZXJpZnkgdGhhdCB0aGUgbm90ZWJvb2sgTUNQIHNlcnZlciBpcyBhbGl2ZS5SBAgBIAAaWcq3LFUKCG5vdGVib29rEklBIHNtYWxsIGluLW1lbW9yeSBub3RlYm9vayBzZXJ2aWNlIGZvciBnZW5lcmF0ZWQgVHlwZVNjcmlwdCBNQ1AgYmluZGluZ3MuYgZwcm90bzM", [file_google_protobuf_timestamp, file_mcp_options_v1_options]); /** * @generated from message standalone.notebook.v1.Note diff --git a/internal/devtools/protobufesnormalize/main.go b/internal/devtools/protobufesnormalize/main.go new file mode 100644 index 0000000..f20697a --- /dev/null +++ b/internal/devtools/protobufesnormalize/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/base64" + "fmt" + "os" + "regexp" + "strings" + + _ "github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" +) + +var fileDescRE = regexp.MustCompile(`fileDesc\("([^"]+)"`) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: protobufesnormalize ...") + os.Exit(2) + } + + for _, path := range os.Args[1:] { + if err := normalizeFile(path); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", path, err) + os.Exit(1) + } + } +} + +func normalizeFile(path string) error { + src, err := os.ReadFile(path) + if err != nil { + return err + } + + changed := false + out := fileDescRE.ReplaceAllFunc(src, func(match []byte) []byte { + parts := fileDescRE.FindSubmatch(match) + if len(parts) != 2 { + return match + } + + normalized, err := normalizeDescriptorString(string(parts[1])) + if err != nil { + return match + } + if normalized == string(parts[1]) { + return match + } + + changed = true + return []byte(`fileDesc("` + normalized + `"`) + }) + if !changed { + return nil + } + return os.WriteFile(path, out, 0o644) +} + +func normalizeDescriptorString(encoded string) (string, error) { + padded := encoded + if rem := len(padded) % 4; rem != 0 { + padded += strings.Repeat("=", 4-rem) + } + + raw, err := base64.StdEncoding.DecodeString(padded) + if err != nil { + return "", err + } + + var descriptor descriptorpb.FileDescriptorProto + if err := proto.Unmarshal(raw, &descriptor); err != nil { + return "", err + } + + deterministic, err := proto.MarshalOptions{Deterministic: true}.Marshal(&descriptor) + if err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(deterministic), nil +} From 6773744102b222698bc4bfa06c07df5a917c5175 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sat, 9 May 2026 02:26:34 +0300 Subject: [PATCH 59/74] feat(13-01): add Python handler option parsing --- internal/codegen/options.go | 24 +++++++ internal/codegen/options_test.go | 112 +++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/internal/codegen/options.go b/internal/codegen/options.go index de02173..a7b767b 100644 --- a/internal/codegen/options.go +++ b/internal/codegen/options.go @@ -23,14 +23,21 @@ const ( PythonRuntimeGrpclib PythonRuntime = "grpclib" ) +type PythonHandler string + +const PythonHandlerDataclass PythonHandler = "dataclass" +const PythonHandlerProtobuf PythonHandler = "protobuf" + type Options struct { Language Language PythonRuntime PythonRuntime + PythonHandler PythonHandler } type OptionsParser struct { opts Options sawPythonRuntime bool + sawPythonHandler bool } func NewOptionsParser() *OptionsParser { @@ -52,6 +59,9 @@ func (p *OptionsParser) Set(name, value string) error { case "python_runtime": p.opts.PythonRuntime = PythonRuntime(value) p.sawPythonRuntime = true + case "python_handler": + p.opts.PythonHandler = PythonHandler(value) + p.sawPythonHandler = true default: return fmt.Errorf("unknown protoc-gen-mcp option %q", name) } @@ -65,19 +75,33 @@ func (p *OptionsParser) Options() (Options, error) { if p.sawPythonRuntime { return Options{}, fmt.Errorf("python_runtime is only supported when lang=python") } + if p.sawPythonHandler { + return Options{}, fmt.Errorf("python_handler is only supported when lang=python") + } case LanguageKotlin, LanguageJava, LanguageTypeScript: if p.sawPythonRuntime { return Options{}, fmt.Errorf("python_runtime is only supported when lang=python") } + if p.sawPythonHandler { + return Options{}, fmt.Errorf("python_handler is only supported when lang=python") + } case LanguagePython: if !p.sawPythonRuntime { p.opts.PythonRuntime = PythonRuntimeGoogleProtobuf } + if !p.sawPythonHandler { + p.opts.PythonHandler = PythonHandlerDataclass + } switch p.opts.PythonRuntime { case PythonRuntimeGoogleProtobuf: default: return Options{}, fmt.Errorf("unsupported python_runtime %q", p.opts.PythonRuntime) } + switch p.opts.PythonHandler { + case PythonHandlerDataclass, PythonHandlerProtobuf: + default: + return Options{}, fmt.Errorf("unsupported python_handler %q", p.opts.PythonHandler) + } default: return Options{}, fmt.Errorf("unsupported lang %q", p.opts.Language) } diff --git a/internal/codegen/options_test.go b/internal/codegen/options_test.go index a6bf5f0..e2f34d7 100644 --- a/internal/codegen/options_test.go +++ b/internal/codegen/options_test.go @@ -14,6 +14,9 @@ func TestParseOptions_DefaultLangGo(t *testing.T) { if opts.PythonRuntime != "" { t.Fatalf("PythonRuntime = %q, want empty", opts.PythonRuntime) } + if opts.PythonHandler != "" { + t.Fatalf("PythonHandler = %q, want empty", opts.PythonHandler) + } } func TestParseOptions_JVMLanguages(t *testing.T) { @@ -52,6 +55,9 @@ func TestParseOptions_JVMLanguages(t *testing.T) { if opts.PythonRuntime != "" { t.Fatalf("PythonRuntime = %q, want empty", opts.PythonRuntime) } + if opts.PythonHandler != "" { + t.Fatalf("PythonHandler = %q, want empty", opts.PythonHandler) + } }) } } @@ -70,6 +76,20 @@ func TestParseOptions_PythonDefaultsRuntime(t *testing.T) { } } +func TestParseOptions_PythonDefaultsHandler(t *testing.T) { + opts, err := ParseOptions("lang=python") + if err != nil { + t.Fatalf("ParseOptions returned error: %v", err) + } + + if opts.Language != LanguagePython { + t.Fatalf("Language = %q, want %q", opts.Language, LanguagePython) + } + if opts.PythonHandler != PythonHandlerDataclass { + t.Fatalf("PythonHandler = %q, want %q", opts.PythonHandler, PythonHandlerDataclass) + } +} + func TestParseOptions_PythonExplicitGoogleProtobufRuntime(t *testing.T) { opts, err := ParseOptions("lang=python,python_runtime=google.protobuf") if err != nil { @@ -84,6 +104,37 @@ func TestParseOptions_PythonExplicitGoogleProtobufRuntime(t *testing.T) { } } +func TestParseOptions_PythonExplicitDataclassHandler(t *testing.T) { + opts, err := ParseOptions("lang=python,python_handler=dataclass") + if err != nil { + t.Fatalf("ParseOptions returned error: %v", err) + } + + if opts.Language != LanguagePython { + t.Fatalf("Language = %q, want %q", opts.Language, LanguagePython) + } + if opts.PythonHandler != PythonHandlerDataclass { + t.Fatalf("PythonHandler = %q, want %q", opts.PythonHandler, PythonHandlerDataclass) + } +} + +func TestParseOptions_PythonExplicitProtobufHandler(t *testing.T) { + opts, err := ParseOptions("lang=python,python_runtime=google.protobuf,python_handler=protobuf") + if err != nil { + t.Fatalf("ParseOptions returned error: %v", err) + } + + if opts.Language != LanguagePython { + t.Fatalf("Language = %q, want %q", opts.Language, LanguagePython) + } + if opts.PythonRuntime != PythonRuntimeGoogleProtobuf { + t.Fatalf("PythonRuntime = %q, want %q", opts.PythonRuntime, PythonRuntimeGoogleProtobuf) + } + if opts.PythonHandler != PythonHandlerProtobuf { + t.Fatalf("PythonHandler = %q, want %q", opts.PythonHandler, PythonHandlerProtobuf) + } +} + func TestParseOptions_PythonRuntimeConstants(t *testing.T) { if PythonRuntimeGoogleProtobuf != "google.protobuf" { t.Fatalf("PythonRuntimeGoogleProtobuf = %q, want %q", PythonRuntimeGoogleProtobuf, "google.protobuf") @@ -96,6 +147,15 @@ func TestParseOptions_PythonRuntimeConstants(t *testing.T) { } } +func TestParseOptions_PythonHandlerConstants(t *testing.T) { + if PythonHandlerDataclass != "dataclass" { + t.Fatalf("PythonHandlerDataclass = %q, want %q", PythonHandlerDataclass, "dataclass") + } + if PythonHandlerProtobuf != "protobuf" { + t.Fatalf("PythonHandlerProtobuf = %q, want %q", PythonHandlerProtobuf, "protobuf") + } +} + func TestParseOptions_PythonRuntimeRejectedForNonPythonLanguages(t *testing.T) { tests := []struct { name string @@ -154,6 +214,52 @@ func TestParseOptions_RejectsUnsupportedPythonRuntime(t *testing.T) { } } +func TestParseOptions_PythonHandlerRejectedForNonPythonLanguages(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "go", + raw: "lang=go,python_handler=protobuf", + }, + { + name: "kotlin", + raw: "lang=kotlin,python_handler=protobuf", + }, + { + name: "java", + raw: "lang=java,python_handler=protobuf", + }, + { + name: "typescript", + raw: "lang=typescript,python_handler=protobuf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseOptions(tt.raw) + if err == nil { + t.Fatal("ParseOptions succeeded, want error") + } + if got, want := err.Error(), "python_handler is only supported when lang=python"; got != want { + t.Fatalf("error = %q, want %q", got, want) + } + }) + } +} + +func TestParseOptions_RejectsUnsupportedPythonHandler(t *testing.T) { + _, err := ParseOptions("lang=python,python_handler=raw") + if err == nil { + t.Fatal("ParseOptions succeeded, want error") + } + if got, want := err.Error(), `unsupported python_handler "raw"`; got != want { + t.Fatalf("error = %q, want %q", got, want) + } +} + func TestParseOptions_IgnoresNonMCPParams(t *testing.T) { opts, err := ParseOptions("paths=source_relative,module=example.com/project,Mfoo.proto=example.com/project/foo,apilevelMbar.proto=API_OPEN,lang=python") if err != nil { @@ -166,6 +272,9 @@ func TestParseOptions_IgnoresNonMCPParams(t *testing.T) { if opts.PythonRuntime != PythonRuntimeGoogleProtobuf { t.Fatalf("PythonRuntime = %q, want %q", opts.PythonRuntime, PythonRuntimeGoogleProtobuf) } + if opts.PythonHandler != PythonHandlerDataclass { + t.Fatalf("PythonHandler = %q, want %q", opts.PythonHandler, PythonHandlerDataclass) + } } func TestOptionsParserSet_IgnoresProtogenManagedParams(t *testing.T) { @@ -198,6 +307,9 @@ func TestOptionsParserSet_IgnoresProtogenManagedParams(t *testing.T) { if opts.PythonRuntime != "" { t.Fatalf("PythonRuntime = %q, want empty", opts.PythonRuntime) } + if opts.PythonHandler != "" { + t.Fatalf("PythonHandler = %q, want empty", opts.PythonHandler) + } } func TestParseOptions_RejectsUnknownLang(t *testing.T) { From 7b67b27e15f267a8179f8f5aa588778cc1879b5b Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sat, 9 May 2026 02:31:44 +0300 Subject: [PATCH 60/74] feat(13-02): add protobuf Python handler surface --- internal/codegen/python_contract_test.go | 123 ++++++++++++++++++++++ internal/codegen/render_python.go | 85 ++++++++++----- internal/codegen/render_python_runtime.go | 7 +- 3 files changed, 187 insertions(+), 28 deletions(-) diff --git a/internal/codegen/python_contract_test.go b/internal/codegen/python_contract_test.go index 5e374b7..9f3bdb3 100644 --- a/internal/codegen/python_contract_test.go +++ b/internal/codegen/python_contract_test.go @@ -68,6 +68,59 @@ func TestPythonRenderer_EmitsDataclassPublicAPI(t *testing.T) { } } +func TestPythonRenderer_EmitsProtobufHandlerPublicAPI(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] + if file == nil { + t.Fatal("example proto file not found in plugin") + } + + model, err := CollectFileModel(file, Options{ + Language: LanguagePython, + PythonRuntime: PythonRuntimeGoogleProtobuf, + PythonHandler: PythonHandlerProtobuf, + }) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + if err := renderPythonFile(plugin, model); err != nil { + t.Fatalf("renderPythonFile: %v", err) + } + + generated := string(generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp.py")) + wantSnippets := []string{ + "ToolRequestContext = mcp.server.session.ServerSession", + "class ExampleAPIToolHandler(Protocol):", + "def create_report(self, ctx: ToolRequestContext, req: example_pb2.CreateReportRequest) -> example_pb2.CreateReportResponse | Awaitable[example_pb2.CreateReportResponse]:", + "def ping(self, ctx: ToolRequestContext, req: example_pb2.PingRequest) -> example_pb2.PingResponse | Awaitable[example_pb2.PingResponse]:", + "def describe_advanced_shapes(self, ctx: ToolRequestContext, req: example_pb2.DescribeAdvancedShapesRequest) -> example_pb2.DescribeAdvancedShapesResponse | Awaitable[example_pb2.DescribeAdvancedShapesResponse]:", + "def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: ExampleAPIToolHandler, *, namespace: str | None = None) -> None:", + "from_pb=_identity,", + "to_pb=_identity,", + "EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON =", + "EXAMPLE_API_HEALTH_OUTPUT_SCHEMA_JSON =", + } + for _, snippet := range wantSnippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated python missing protobuf handler snippet %q\n%s", snippet, generated) + } + } + notWantSnippets := []string{ + "UNSET = _UnsetType()", + "@dataclass(slots=True)", + "class CreateReportRequest:", + "class CreateReportResponse:", + "def _from_pb_create_report_request", + "def _to_pb_create_report_response", + "def create_report(self, ctx: ToolRequestContext, req: CreateReportRequest)", + } + for _, snippet := range notWantSnippets { + if strings.Contains(generated, snippet) { + t.Fatalf("generated python must not retain dataclass handler snippet %q\n%s", snippet, generated) + } + } +} + func TestPythonAndGoRenderersShareContractModel(t *testing.T) { plugin := newExampleProtogenPlugin(t) file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] @@ -127,6 +180,76 @@ func TestPythonAndGoRenderersShareContractModel(t *testing.T) { } } +func TestPythonRenderer_ProtobufHandlerImportsCrossFileProtobufModules(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/shared.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/shared;sharedv1";`, + `message SharedRequest {}`, + `message SharedResponse {}`, + ``, + }, "\n"), + "test/v1/service.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/service;servicev1";`, + `import "test/v1/shared.proto";`, + `service CrossFileAPI {`, + ` rpc UseShared(SharedRequest) returns (SharedResponse);`, + `}`, + ``, + }, "\n"), + }, "test/v1/service.proto") + + file := plugin.FilesByPath["test/v1/service.proto"] + if file == nil { + t.Fatal("service proto file not found in plugin") + } + sharedFile := plugin.FilesByPath["test/v1/shared.proto"] + if sharedFile == nil { + t.Fatal("shared proto file not found in plugin") + } + sharedPBAlias := pythonModuleAlias(sharedFile, false) + sharedImport := "from test.v1 import shared_pb2" + if sharedPBAlias != "shared_pb2" { + sharedImport += " as " + sharedPBAlias + } + + model, err := CollectFileModel(file, Options{ + Language: LanguagePython, + PythonRuntime: PythonRuntimeGoogleProtobuf, + PythonHandler: PythonHandlerProtobuf, + }) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + if err := renderPythonFile(plugin, model); err != nil { + t.Fatalf("renderPythonFile: %v", err) + } + + generated := string(generatedFileContent(t, plugin, "test/v1/service_mcp.py")) + wantSnippets := []string{ + sharedImport, + "def use_shared(self, ctx: ToolRequestContext, req: " + sharedPBAlias + ".SharedRequest) -> " + sharedPBAlias + ".SharedResponse | Awaitable[" + sharedPBAlias + ".SharedResponse]:", + } + for _, snippet := range wantSnippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated python missing protobuf cross-file snippet %q\n%s", snippet, generated) + } + } + notWantSnippets := []string{ + "shared_mcp", + "def _from_pb_", + "def _to_pb_", + } + for _, snippet := range notWantSnippets { + if strings.Contains(generated, snippet) { + t.Fatalf("generated python must not retain cross-file dataclass snippet %q\n%s", snippet, generated) + } + } +} + func TestPythonRenderer_ImportsCrossFilePublicTypes(t *testing.T) { plugin := newTempProtogenPlugin(t, map[string]string{ "test/v1/shared.proto": strings.Join([]string{ diff --git a/internal/codegen/render_python.go b/internal/codegen/render_python.go index 7964aae..1ffa5d5 100644 --- a/internal/codegen/render_python.go +++ b/internal/codegen/render_python.go @@ -14,6 +14,7 @@ func renderPythonFile(plugin *protogen.Plugin, model FileModel) error { if err != nil { return err } + protobufHandlerMode := model.Options.PythonHandler == PythonHandlerProtobuf filename := pythonOutputPath(info.file) generated := plugin.NewGeneratedFile(filename, "") @@ -35,19 +36,21 @@ func renderPythonFile(plugin *protogen.Plugin, model FileModel) error { generated.P("import mcp.shared.exceptions") generated.P("import mcp.types") generated.P("from google.protobuf import any_pb2, duration_pb2, empty_pb2, field_mask_pb2, json_format, struct_pb2, timestamp_pb2, wrappers_pb2") - for _, moduleRef := range info.publicImportsForModel(model) { - if moduleRef.Package == "" { + if !protobufHandlerMode { + for _, moduleRef := range info.publicImportsForModel(model) { + if moduleRef.Package == "" { + if moduleRef.Alias == moduleRef.Module { + generated.P("import ", moduleRef.Module) + } else { + generated.P("import ", moduleRef.Module, " as ", moduleRef.Alias) + } + continue + } if moduleRef.Alias == moduleRef.Module { - generated.P("import ", moduleRef.Module) + generated.P("from ", moduleRef.Package, " import ", moduleRef.Module) } else { - generated.P("import ", moduleRef.Module, " as ", moduleRef.Alias) + generated.P("from ", moduleRef.Package, " import ", moduleRef.Module, " as ", moduleRef.Alias) } - continue - } - if moduleRef.Alias == moduleRef.Module { - generated.P("from ", moduleRef.Package, " import ", moduleRef.Module) - } else { - generated.P("from ", moduleRef.Package, " import ", moduleRef.Module, " as ", moduleRef.Alias) } } for _, moduleRef := range info.protobufImportsForModel(model) { @@ -79,22 +82,22 @@ func renderPythonFile(plugin *protogen.Plugin, model FileModel) error { generated.P() generated.P("ToolRequestContext = mcp.server.session.ServerSession") generated.P() - if err := renderPythonPublicTypes(generated, info, model); err != nil { - return err + if !protobufHandlerMode { + if err := renderPythonPublicTypes(generated, info, model); err != nil { + return err + } } - renderPythonRuntime(generated) - if err := renderPythonMappers(generated, info, model); err != nil { - return err + renderPythonRuntime(generated, protobufHandlerMode) + if !protobufHandlerMode { + if err := renderPythonMappers(generated, info, model); err != nil { + return err + } } for serviceIdx, service := range model.Services { generated.P("class ", pythonProtocolName(service.ProtoName), "(Protocol):") for _, method := range service.Methods { - inputType, err := info.pythonPublicMethodTypeRef(method.Input) - if err != nil { - return err - } - outputType, err := info.pythonPublicMethodTypeRef(method.Output) + inputType, outputType, err := info.pythonHandlerMethodTypeRefs(method, protobufHandlerMode) if err != nil { return err } @@ -119,13 +122,17 @@ func renderPythonFile(plugin *protogen.Plugin, model FileModel) error { if err != nil { return err } - inputMapper, err := info.pythonMapperHelperForMethodType("from_pb", method.Input) - if err != nil { - return err - } - outputMapper, err := info.pythonMapperHelperForMethodType("to_pb", method.Output) - if err != nil { - return err + inputMapper := "_identity" + outputMapper := "_identity" + if !protobufHandlerMode { + inputMapper, err = info.pythonMapperHelperForMethodType("from_pb", method.Input) + if err != nil { + return err + } + outputMapper, err = info.pythonMapperHelperForMethodType("to_pb", method.Output) + if err != nil { + return err + } } schemaName := pythonSchemaConst(service.ProtoName, method.Name) generated.P(" registry.add_tool(_RegisteredTool(") @@ -160,6 +167,30 @@ func renderPythonFile(plugin *protogen.Plugin, model FileModel) error { return nil } +func (p pythonRenderInfo) pythonHandlerMethodTypeRefs(method MethodModel, protobufHandlerMode bool) (string, string, error) { + if protobufHandlerMode { + inputType, err := p.pythonProtobufTypeRef(method.Input) + if err != nil { + return "", "", err + } + outputType, err := p.pythonProtobufTypeRef(method.Output) + if err != nil { + return "", "", err + } + return inputType, outputType, nil + } + + inputType, err := p.pythonPublicMethodTypeRef(method.Input) + if err != nil { + return "", "", err + } + outputType, err := p.pythonPublicMethodTypeRef(method.Output) + if err != nil { + return "", "", err + } + return inputType, outputType, nil +} + type pythonRenderInfo struct { file *protogen.File messages map[string]*protogen.Message diff --git a/internal/codegen/render_python_runtime.go b/internal/codegen/render_python_runtime.go index 064dd16..3cb8933 100644 --- a/internal/codegen/render_python_runtime.go +++ b/internal/codegen/render_python_runtime.go @@ -2,7 +2,7 @@ package codegen import "google.golang.org/protobuf/compiler/protogen" -func renderPythonRuntime(generated *protogen.GeneratedFile) { +func renderPythonRuntime(generated *protogen.GeneratedFile, emitIdentity bool) { generated.P("@dataclass(frozen=True)") generated.P("class _RegisteredTool:") generated.P(" name: str") @@ -49,6 +49,11 @@ func renderPythonRuntime(generated *protogen.GeneratedFile) { generated.P(" json_format.ParseDict(arguments, message)") generated.P(" return message") generated.P() + if emitIdentity { + generated.P("def _identity(value: Any) -> Any:") + generated.P(" return value") + generated.P() + } generated.P("def _normalize_tool_segment(segment: str | None) -> str:") generated.P(" if segment is None:") generated.P(" return \"\"") From 7e7c2fcedefb856076931b8bae497d7a09b95f81 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sat, 9 May 2026 02:43:24 +0300 Subject: [PATCH 61/74] fix(13-02): skip protobuf-only public type sidecars --- internal/codegen/generator.go | 3 ++ internal/codegen/generator_test.go | 45 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 2dccf79..6552b8d 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -252,6 +252,9 @@ func pythonModelRequiresOutput(model FileModel) bool { if len(model.Services) > 0 { return true } + if model.Options.PythonHandler == PythonHandlerProtobuf { + return false + } if model.PythonTypes == nil { return false } diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 03482f5..4487990 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -292,6 +292,51 @@ func TestGenerate_PythonEmitsCrossFilePublicTypeModules(t *testing.T) { } } +func TestGenerate_PythonProtobufHandlerSkipsCrossFilePublicTypeModules(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/shared.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/shared;sharedv1";`, + `message SharedRequest {}`, + `message SharedResponse {}`, + ``, + }, "\n"), + "test/v1/service.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/service;servicev1";`, + `import "test/v1/shared.proto";`, + `service CrossFileAPI {`, + ` rpc UseShared(SharedRequest) returns (SharedResponse);`, + `}`, + ``, + }, "\n"), + }, "test/v1/shared.proto", "test/v1/service.proto") + + if err := Generate(plugin, Options{ + Language: LanguagePython, + PythonRuntime: PythonRuntimeGoogleProtobuf, + PythonHandler: PythonHandlerProtobuf, + }); err != nil { + t.Fatalf("Generate: %v", err) + } + + for _, file := range plugin.Response().GetFile() { + if file.GetName() == "test/v1/shared_mcp.py" { + t.Fatalf("protobuf handler mode must not emit message-only public type module:\n%s", file.GetContent()) + } + } + + serviceGenerated := string(generatedFileContent(t, plugin, "test/v1/service_mcp.py")) + if !strings.Contains(serviceGenerated, "from test.v1 import shared_pb2") { + t.Fatalf("service module must import shared protobuf module\n%s", serviceGenerated) + } + if strings.Contains(serviceGenerated, "shared_mcp") { + t.Fatalf("protobuf handler mode must not import generated shared public module\n%s", serviceGenerated) + } +} + func TestGenerate_PythonEmitsPublicTypesForHiddenOnlyServiceImports(t *testing.T) { plugin := newTempProtogenPlugin(t, map[string]string{ "test/v1/shared.proto": strings.Join([]string{ From 9d7c9783aa79b56d0426732416681686ee5ba18e Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sat, 9 May 2026 02:46:44 +0300 Subject: [PATCH 62/74] docs(13): document Python handler selection --- AGENTS.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f63ef14..2d1316f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,9 +170,12 @@ architecture unless explicitly revised. - Generated code exposes `RegisterTools(server, impl, opts...) error` - Generated Python modules expose `ToolHandler` - Generated Python modules expose `register__tools(server, impl, *, namespace=None)` -- Generated Python modules expose dataclasses, `UNSET`, and explicit `oneof` - wrapper variants from `*_mcp.py`; user handler code should not depend on - `*_pb2.py` +- Generated Python modules default to `python_handler=dataclass`, exposing + dataclasses, `UNSET`, explicit `oneof` wrapper variants, mapper helpers, and + handler protocols using generated public dataclass types from `*_mcp.py` +- Generated Python modules also support opt-in `python_handler=protobuf`, + where handler protocols accept and return raw generated `*_pb2` message + classes and generated dataclass public types/mapper helpers are omitted - Generated Kotlin files expose `ToolHandler` - Generated Kotlin files expose `registerTools(server: Server, impl: ToolHandler, namespace: String? = null)` @@ -198,7 +201,8 @@ architecture unless explicitly revised. - Implemented: - `cmd/protoc-gen-mcp` plugin scaffold and generated `*.mcp.go` bindings - typed plugin option parsing for `lang=go|python|kotlin|java|typescript` and - `python_runtime=google.protobuf|betterproto|grpclib` + `python_runtime=google.protobuf|betterproto|grpclib`, plus Python-only + `python_handler=dataclass|protobuf` - generated TypeScript `*_mcp.ts` sidecars for `lang=typescript`, targeting the official `@modelcontextprotocol/sdk` low-level `Server` import path and Protobuf-ES `_pb.js` modules, including typed `ToolHandler` @@ -237,11 +241,17 @@ architecture unless explicitly revised. Go-specific proto options just to run `lang=python`, `lang=java`, `lang=kotlin`, or `lang=typescript` - generated self-contained Python `*_mcp.py` bindings for - `lang=python,python_runtime=google.protobuf`, including handler protocols, - dataclasses, `UNSET`, explicit `oneof` wrapper variants, schema JSON - constants, shared per-server registry wiring, namespace-aware - `register__tools(...)`, generated protobuf<->dataclass - mappers, and ProtoJSON dict/message conversion via `json_format.ParseDict` + `lang=python,python_runtime=google.protobuf`, including default + dataclass-mode handler protocols, dataclasses, `UNSET`, explicit `oneof` + wrapper variants, schema JSON constants, shared per-server registry wiring, + namespace-aware `register__tools(...)`, generated + protobuf<->dataclass mappers, and ProtoJSON dict/message conversion via + `json_format.ParseDict` + - opt-in generated Python `python_handler=protobuf` mode, where handler + protocols use raw generated `*_pb2` request/response message classes, + registration uses identity converters through the existing + `_RegisteredTool.from_pb/to_pb` seam, and message-only dependency files do + not emit empty public `*_mcp.py` sidecars - generated Python `mcp/__init__.py` bridge support file so `mcp.options.*` protobuf output coexists with the official `mcp` SDK package namespace - generated Python package `__init__.py` files next to `*_mcp.py` output so @@ -355,6 +365,9 @@ architecture unless explicitly revised. - `easyp` lint and generation flows for `mcp` and `internal/testproto` - `go test ./internal/codegen -count=1` for generator, Go/Python/Kotlin/Java, and shared JVM foundation coverage + - `go test ./internal/codegen -run 'TestParseOptions|TestPythonRenderer_EmitsDataclassPublicAPI|TestPythonRenderer_EmitsProtobufHandlerPublicAPI|TestPythonRenderer_ProtobufHandlerImportsCrossFileProtobufModules|TestGenerate_PythonProtobufHandlerSkipsCrossFilePublicTypeModules' -count=1` + for Python handler option parsing and generated dataclass/protobuf handler + API contracts - `go test ./internal/codegen -run 'TestGenerateKotlinExampleGolden|TestKotlinContract_.*' -count=1` for Kotlin golden output and focused Kotlin renderer contracts - `go test ./internal/codegen -run 'TestJavaContract_.*|TestGenerateJavaExampleGolden|TestGenerateJavaExampleHandlerCompileSmoke|TestGenerate_JavaTargetEmitsOutput' -count=1` @@ -453,6 +466,8 @@ architecture unless explicitly revised. - `go test ./internal/codegen -run TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext -count=1` - Run generated Node stdio tests: - `go test ./internal/codegen -run 'TestTypeScriptGeneratedNodeServer.*OverStdio|TestTypeScriptGeneratedNodeServerRejectsInvalid(Input|Output)OverStdio' -count=1` +- Run focused Python handler option and renderer tests: + - `go test ./internal/codegen -run 'TestParseOptions|TestPythonRenderer_EmitsDataclassPublicAPI|TestPythonRenderer_EmitsProtobufHandlerPublicAPI|TestPythonRenderer_ProtobufHandlerImportsCrossFileProtobufModules|TestGenerate_PythonProtobufHandlerSkipsCrossFilePublicTypeModules' -count=1` - Run JVM compile gate: - `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` - Install JVM example scripts: From 4803fb7869077c5002c3d3bb6d4fa7a8d6023b81 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sat, 9 May 2026 03:46:03 +0300 Subject: [PATCH 63/74] test(14-01): prove protobuf Python runtime registration - Add hermetic protobuf-mode Python runtime harness - Cover public low-level server registration with namespace normalization - Assert raw pb2 dispatch and text/structured output parity --- .../codegen/python_protobuf_runtime_test.go | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 internal/codegen/python_protobuf_runtime_test.go diff --git a/internal/codegen/python_protobuf_runtime_test.go b/internal/codegen/python_protobuf_runtime_test.go new file mode 100644 index 0000000..67cecd1 --- /dev/null +++ b/internal/codegen/python_protobuf_runtime_test.go @@ -0,0 +1,250 @@ +package codegen + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/easyp-tech/protoc-gen-mcp/internal/pythontest" +) + +func renderExamplePythonForProtobufRuntimeTests(t *testing.T) string { + t.Helper() + + plugin := newExampleProtogenPlugin(t) + file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] + if file == nil { + t.Fatal("example proto file not found in plugin") + } + + model, err := CollectFileModel(file, Options{ + Language: LanguagePython, + PythonRuntime: PythonRuntimeGoogleProtobuf, + PythonHandler: PythonHandlerProtobuf, + }) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + if err := renderPythonFile(plugin, model); err != nil { + t.Fatalf("renderPythonFile: %v", err) + } + + return string(generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp.py")) +} + +func repoRootForPythonProtobufRuntimeTests(t *testing.T) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) +} + +func prepareExamplePythonProtobufRuntime(t *testing.T) (tempRoot string, repoRoot string) { + t.Helper() + + repoRoot = repoRootForPythonProtobufRuntimeTests(t) + tempRoot = t.TempDir() + + packageDir := filepath.Join(tempRoot, "internal", "testproto", "example", "v1") + if err := os.MkdirAll(packageDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", packageDir, err) + } + + for _, rel := range []string{ + "internal/__init__.py", + "internal/testproto/__init__.py", + "internal/testproto/example/__init__.py", + "internal/testproto/example/v1/__init__.py", + } { + path := filepath.Join(tempRoot, filepath.FromSlash(rel)) + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", path, err) + } + } + + generatedPath := filepath.Join(packageDir, "example_mcp.py") + if err := os.WriteFile(generatedPath, []byte(renderExamplePythonForProtobufRuntimeTests(t)), 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", generatedPath, err) + } + + examplePB2Path := filepath.Join(repoRoot, "internal", "testproto", "example", "v1", "example_pb2.py") + examplePB2Content, err := os.ReadFile(examplePB2Path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", examplePB2Path, err) + } + if err := os.WriteFile(filepath.Join(packageDir, "example_pb2.py"), examplePB2Content, 0o644); err != nil { + t.Fatalf("WriteFile(example_pb2.py): %v", err) + } + + return tempRoot, repoRoot +} + +func runExamplePythonProtobufScript(t *testing.T, script string) { + t.Helper() + + tempRoot, repoRoot := prepareExamplePythonProtobufRuntime(t) + cmd := pythontest.Command(t, "-c", fmt.Sprintf( + "from internal.testproto.example.v1 import example_mcp as module\n"+ + "from internal.testproto.example.v1 import example_pb2\n%s", + script, + )) + cmd.Dir = tempRoot + cmd.Env = pythontest.Env(t, "PYTHONPATH="+tempRoot+string(os.PathListSeparator)+repoRoot) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("python protobuf runtime failed: %v\n%s", err, output) + } +} + +func TestPythonProtobufRuntime_RegistersToolsThroughLowLevelServer(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio +import json +import mcp.server.lowlevel +import mcp.server.lowlevel.server as lowlevel_server +import mcp.types +from mcp.shared.context import RequestContext + +seen_requests = [] + +class Handler: + def create_report(self, _ctx, req): + assert isinstance(req, example_pb2.CreateReportRequest) + seen_requests.append(req) + return example_pb2.CreateReportResponse( + report_id="pb-1", + total_count=req.count, + status=example_pb2.REPORT_STATUS_OK, + details=req.details, + warnings=[], + ) + + def ping(self, _ctx, _req): + return example_pb2.PingResponse() + + def describe_advanced_shapes(self, _ctx, req): + return example_pb2.DescribeAdvancedShapesResponse( + labels=req.labels, + quantities=req.quantities, + toggles=req.toggles, + limits=req.limits, + ) + + def describe_scalar_shapes(self, _ctx, req): + return example_pb2.DescribeScalarShapesResponse( + bool_flag=req.bool_flag, + text_value=req.text_value, + bytes_value=req.bytes_value, + int32_value=req.int32_value, + sint32_value=req.sint32_value, + sfixed32_value=req.sfixed32_value, + uint32_value=req.uint32_value, + fixed32_value=req.fixed32_value, + int64_value=req.int64_value, + sint64_value=req.sint64_value, + sfixed64_value=req.sfixed64_value, + uint64_value=req.uint64_value, + fixed64_value=req.fixed64_value, + float_value=req.float_value, + double_value=req.double_value, + status=req.status, + details=req.details, + samples=req.samples, + ) + + def hidden_thing(self, _ctx, _req): + return example_pb2.HiddenThingResponse() + +server = mcp.server.lowlevel.Server("protobuf-runtime-test", version="1.0.0") +module.register_example_api_tools(server, Handler(), namespace="raw.pb") + +async def main(): + tools_result = await server.request_handlers[mcp.types.ListToolsRequest](mcp.types.ListToolsRequest()) + tool_names = [tool.name for tool in tools_result.root.tools] + assert "raw_pb_CreateReport" in tool_names + assert "raw_pb_Health" in tool_names + assert all("." not in name for name in tool_names) + + token = lowlevel_server.request_ctx.set(RequestContext("test", None, object(), None)) + try: + result = await server.request_handlers[mcp.types.CallToolRequest]( + mcp.types.CallToolRequest( + params=mcp.types.CallToolRequestParams( + name="raw_pb_CreateReport", + arguments={"city": "Paris", "count": 2, "details": {"label": "today"}}, + ), + ), + ) + finally: + lowlevel_server.request_ctx.reset(token) + + assert len(seen_requests) == 1 + assert result.root.structuredContent["reportId"] == "pb-1" + assert result.root.structuredContent["totalCount"] == "2" + assert result.root.structuredContent["status"] == "REPORT_STATUS_OK" + assert json.loads(result.root.content[0].text) == result.root.structuredContent + +asyncio.run(main()) +`) +} + +func TestPythonProtobufRuntime_DispatchesRawPB2Handlers(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio +import json + +class DummyServer: + pass + +registry = module._ServerToolRegistry(DummyServer()) +seen_requests = [] + +def handler(_ctx, req): + assert isinstance(req, example_pb2.CreateReportRequest) + seen_requests.append(req) + return example_pb2.CreateReportResponse( + report_id="pb-raw", + total_count=req.count, + status=example_pb2.REPORT_STATUS_OK, + details=req.details, + warnings=["protobuf"], + ) + +registry.add_tool(module._RegisteredTool( + name="example_CreateReport", + title="Create report", + description="Create a report for a city.", + input_schema_json=module.EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + output_schema_json=module.EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + request_type=example_pb2.CreateReportRequest, + response_type=example_pb2.CreateReportResponse, + from_pb=module._identity, + to_pb=module._identity, + handler=handler, + annotations=None, + icons=None, +)) + +result = asyncio.run(module._dispatch_call( + registry, + "example_CreateReport", + {"city": "Paris", "count": 2, "details": {"label": "today"}}, + None, +)) + +assert len(seen_requests) == 1 +assert isinstance(seen_requests[0], example_pb2.CreateReportRequest) +assert result.isError in (None, False) +assert result.structuredContent["reportId"] == "pb-raw" +assert result.structuredContent["totalCount"] == "2" +assert result.structuredContent["status"] == "REPORT_STATUS_OK" +assert result.structuredContent["warnings"] == ["protobuf"] +assert json.loads(result.content[0].text) == result.structuredContent +`) +} From ef9af6d1a9979a4dddafc9e7e7533e95b5ceaaa4 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sat, 9 May 2026 03:47:31 +0300 Subject: [PATCH 64/74] test(14-01): cover protobuf Python invalid input paths - Assert schema-invalid payloads fail before raw handlers run - Assert schema-valid ProtoJSON-invalid payloads fail in ParseDict - Check protobuf mode reports INVALID_PARAMS through the shared runtime --- .../codegen/python_protobuf_runtime_test.go | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/internal/codegen/python_protobuf_runtime_test.go b/internal/codegen/python_protobuf_runtime_test.go index 67cecd1..250ea1c 100644 --- a/internal/codegen/python_protobuf_runtime_test.go +++ b/internal/codegen/python_protobuf_runtime_test.go @@ -248,3 +248,123 @@ assert result.structuredContent["warnings"] == ["protobuf"] assert json.loads(result.content[0].text) == result.structuredContent `) } + +func TestPythonProtobufRuntime_InvalidSchemaPayloadRaisesInvalidParams(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio +import mcp.shared.exceptions +import mcp.types + +class DummyServer: + pass + +registry = module._ServerToolRegistry(DummyServer()) + +def handler(_ctx, _req): + raise AssertionError("handler must not run for schema-invalid input") + +registry.add_tool(module._RegisteredTool( + name="example_CreateReport", + title="Create report", + description="Create a report for a city.", + input_schema_json=module.EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + output_schema_json=module.EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + request_type=example_pb2.CreateReportRequest, + response_type=example_pb2.CreateReportResponse, + from_pb=module._identity, + to_pb=module._identity, + handler=handler, + annotations=None, + icons=None, +)) + +async def main(): + try: + await module._dispatch_call( + registry, + "example_CreateReport", + { + "city": "Paris", + "count": "two", + "details": {"label": "today"}, + }, + None, + ) + except mcp.shared.exceptions.McpError as exc: + assert exc.error.code == mcp.types.INVALID_PARAMS + assert "example_CreateReport" in exc.error.message + assert "invalid arguments for tool" in exc.error.message + else: + raise AssertionError("expected McpError for schema validation failure") + +asyncio.run(main()) +`) +} + +func TestPythonProtobufRuntime_InvalidProtoJSONPayloadRaisesInvalidParams(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio +import mcp.shared.exceptions +import mcp.types + +class DummyServer: + pass + +registry = module._ServerToolRegistry(DummyServer()) + +def handler(_ctx, _req): + raise AssertionError("handler must not run for protojson-invalid input") + +registry.add_tool(module._RegisteredTool( + name="example_DescribeScalarShapes", + title="Describe scalar shapes", + description="Describe scalar protobuf kinds.", + input_schema_json=module.EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_INPUT_SCHEMA_JSON, + output_schema_json=module.EXAMPLE_API_DESCRIBE_SCALAR_SHAPES_OUTPUT_SCHEMA_JSON, + request_type=example_pb2.DescribeScalarShapesRequest, + response_type=example_pb2.DescribeScalarShapesResponse, + from_pb=module._identity, + to_pb=module._identity, + handler=handler, + annotations=None, + icons=None, +)) + +invalid_arguments = { + "boolFlag": True, + "textValue": "hello", + "bytesValue": "YWJj", + "int32Value": -1, + "sint32Value": -2, + "sfixed32Value": -3, + "uint32Value": 7, + "fixed32Value": 8, + "int64Value": "-9", + "sint64Value": "-10", + "sfixed64Value": "-11", + "uint64Value": "12", + "fixed64Value": "not-an-int", + "floatValue": 1.25, + "doubleValue": 2.5, + "status": "REPORT_STATUS_OK", + "details": {"label": "shape"}, +} + +async def main(): + try: + await module._dispatch_call( + registry, + "example_DescribeScalarShapes", + invalid_arguments, + None, + ) + except mcp.shared.exceptions.McpError as exc: + assert exc.error.code == mcp.types.INVALID_PARAMS + assert "example_DescribeScalarShapes" in exc.error.message + assert "invalid arguments for tool" in exc.error.message + else: + raise AssertionError("expected McpError for protojson parse failure") + +asyncio.run(main()) +`) +} From 4d74bea21414a07bc4d55642622e6db86c407bd2 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Sat, 9 May 2026 03:48:44 +0300 Subject: [PATCH 65/74] test(14-01): verify protobuf Python output failures - Cover invalid enum output through protobuf-mode validation - Cover non-message handler output through marshal wrapping - Keep failure assertions on the shared mcpruntime prefixes --- .../codegen/python_protobuf_runtime_test.go | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/internal/codegen/python_protobuf_runtime_test.go b/internal/codegen/python_protobuf_runtime_test.go index 250ea1c..4e8aeb6 100644 --- a/internal/codegen/python_protobuf_runtime_test.go +++ b/internal/codegen/python_protobuf_runtime_test.go @@ -368,3 +368,99 @@ async def main(): asyncio.run(main()) `) } + +func TestPythonProtobufRuntime_RejectsInvalidOutput(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio + +class DummyServer: + pass + +registry = module._ServerToolRegistry(DummyServer()) + +def handler(_ctx, req): + return example_pb2.CreateReportResponse( + report_id="report-1", + total_count=req.count, + status=123, + details=req.details, + warnings=[], + ) + +registry.add_tool(module._RegisteredTool( + name="example_CreateReport", + title="Create report", + description="Create a report for a city.", + input_schema_json=module.EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + output_schema_json=module.EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + request_type=example_pb2.CreateReportRequest, + response_type=example_pb2.CreateReportResponse, + from_pb=module._identity, + to_pb=module._identity, + handler=handler, + annotations=None, + icons=None, +)) + +try: + asyncio.run(module._dispatch_call( + registry, + "example_CreateReport", + { + "city": "Paris", + "count": 2, + "details": {"label": "today"}, + }, + None, + )) +except RuntimeError as exc: + assert "mcpruntime: validate output for tool 'example_CreateReport'" in str(exc) +else: + raise AssertionError("expected RuntimeError for unknown enum output") +`) +} + +func TestPythonProtobufRuntime_WrapsOutputMarshalFailures(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio + +class DummyServer: + pass + +registry = module._ServerToolRegistry(DummyServer()) + +async def handler(_ctx, _req): + return object() + +registry.add_tool(module._RegisteredTool( + name="example_CreateReport", + title="Create report", + description="Create a report for a city.", + input_schema_json=module.EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + output_schema_json=module.EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + request_type=example_pb2.CreateReportRequest, + response_type=example_pb2.CreateReportResponse, + from_pb=module._identity, + to_pb=module._identity, + handler=handler, + annotations=None, + icons=None, +)) + +try: + asyncio.run(module._dispatch_call( + registry, + "example_CreateReport", + { + "city": "Paris", + "count": 2, + "details": {"label": "today"}, + }, + None, + )) +except RuntimeError as exc: + assert "mcpruntime: marshal output for tool 'example_CreateReport'" in str(exc) +else: + raise AssertionError("expected RuntimeError for output marshal failure") +`) +} From 3487e9127aaa9972ef7776b47611358829714ba9 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 17:16:11 +0300 Subject: [PATCH 66/74] test(14-02): cover protobuf Python runtime parity edges --- .../codegen/python_protobuf_runtime_test.go | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/internal/codegen/python_protobuf_runtime_test.go b/internal/codegen/python_protobuf_runtime_test.go index 4e8aeb6..880f9a1 100644 --- a/internal/codegen/python_protobuf_runtime_test.go +++ b/internal/codegen/python_protobuf_runtime_test.go @@ -464,3 +464,179 @@ else: raise AssertionError("expected RuntimeError for output marshal failure") `) } + +func TestPythonProtobufRuntime_DispatchesAsyncHandlers(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio + +class DummyServer: + pass + +registry = module._ServerToolRegistry(DummyServer()) + +async def handler(_ctx, req): + await asyncio.sleep(0) + assert isinstance(req, example_pb2.CreateReportRequest) + return example_pb2.CreateReportResponse( + report_id="async-42", + total_count=req.count, + status=example_pb2.REPORT_STATUS_OK, + details=req.details, + ) + +registry.add_tool(module._RegisteredTool( + name="example_CreateReport", + title="Create report", + description="Create a report for a city.", + input_schema_json=module.EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + output_schema_json=module.EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + request_type=example_pb2.CreateReportRequest, + response_type=example_pb2.CreateReportResponse, + from_pb=module._identity, + to_pb=module._identity, + handler=handler, + annotations=None, + icons=None, +)) + +result = asyncio.run(module._dispatch_call( + registry, + "example_CreateReport", + { + "city": "Paris", + "count": 2, + "details": {"label": "today"}, + }, + None, +)) +assert result.structuredContent["reportId"] == "async-42" +assert result.structuredContent["totalCount"] == "2" +assert result.structuredContent["status"] == "REPORT_STATUS_OK" +`) +} + +func TestPythonProtobufRuntime_HandlerBusinessErrorsReturnToolErrorResult(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio + +class DummyServer: + pass + +registry = module._ServerToolRegistry(DummyServer()) + +def handler(_ctx, _req): + raise RuntimeError("boom") + +registry.add_tool(module._RegisteredTool( + name="example_CreateReport", + title="Create report", + description="Create a report for a city.", + input_schema_json=module.EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + output_schema_json=module.EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + request_type=example_pb2.CreateReportRequest, + response_type=example_pb2.CreateReportResponse, + from_pb=module._identity, + to_pb=module._identity, + handler=handler, + annotations=None, + icons=None, +)) + +result = asyncio.run(module._dispatch_call( + registry, + "example_CreateReport", + { + "city": "Paris", + "count": 2, + "details": {"label": "today"}, + }, + None, +)) +assert result.isError is True +assert result.content[0].type == "text" +assert result.content[0].text == "boom" +assert result.structuredContent is None +`) +} + +func TestPythonProtobufRuntime_UnknownToolRaisesInvalidParams(t *testing.T) { + runExamplePythonProtobufScript(t, ` +import asyncio +import mcp.types +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import McpError + +class Handler(module.ExampleAPIToolHandler): + def create_report(self, _ctx, _req): + return example_pb2.CreateReportResponse( + report_id="ok", + status=example_pb2.REPORT_STATUS_OK, + details=example_pb2.ReportDetails(label="ok"), + ) + + def ping(self, _ctx, _req): + return example_pb2.PingResponse() + + def describe_advanced_shapes(self, _ctx, _req): + return example_pb2.DescribeAdvancedShapesResponse() + + def describe_scalar_shapes(self, _ctx, _req): + return example_pb2.DescribeScalarShapesResponse() + + def hidden_thing(self, _ctx, _req): + return example_pb2.HiddenThingResponse() + +async def main(): + server = Server("protobuf-runtime-test", version="1.0.0") + module.register_example_api_tools(server, Handler()) + registry = module._get_registry(server) + + try: + await registry.call_tool("missing_tool", {"city": "Paris", "count": 2}) + except McpError as exc: + assert exc.error.code == mcp.types.INVALID_PARAMS + assert "missing_tool" in exc.error.message + assert "unknown tool" in exc.error.message + else: + raise AssertionError("expected missing tool to raise McpError") + +asyncio.run(main()) +`) +} + +func TestPythonProtobufRuntime_DuplicateRegistrationFails(t *testing.T) { + runExamplePythonProtobufScript(t, ` +from mcp.server.lowlevel import Server + +class Handler(module.ExampleAPIToolHandler): + def create_report(self, _ctx, _req): + return example_pb2.CreateReportResponse( + report_id="ok", + status=example_pb2.REPORT_STATUS_OK, + details=example_pb2.ReportDetails(label="ok"), + ) + + def ping(self, _ctx, _req): + return example_pb2.PingResponse() + + def describe_advanced_shapes(self, _ctx, _req): + return example_pb2.DescribeAdvancedShapesResponse() + + def describe_scalar_shapes(self, _ctx, _req): + return example_pb2.DescribeScalarShapesResponse() + + def hidden_thing(self, _ctx, _req): + return example_pb2.HiddenThingResponse() + +server = Server("protobuf-runtime-test", version="1.0.0") +handler = Handler() +module.register_example_api_tools(server, handler) + +try: + module.register_example_api_tools(server, handler) +except ValueError as exc: + assert "duplicate tool registration: example_CreateReport" in str(exc) +else: + raise AssertionError("expected duplicate registration to fail") +`) +} From 1f358500feef9b51d9758db826038062ce3c176d Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 17:18:10 +0300 Subject: [PATCH 67/74] test(14-02): cover protobuf Python metadata parity --- internal/codegen/python_contract_test.go | 65 +++++++++++++++++++ .../codegen/python_protobuf_runtime_test.go | 43 ++++++++++++ 2 files changed, 108 insertions(+) diff --git a/internal/codegen/python_contract_test.go b/internal/codegen/python_contract_test.go index 9f3bdb3..9461848 100644 --- a/internal/codegen/python_contract_test.go +++ b/internal/codegen/python_contract_test.go @@ -121,6 +121,71 @@ func TestPythonRenderer_EmitsProtobufHandlerPublicAPI(t *testing.T) { } } +func TestPythonProtobufRenderer_ProjectsMetadataInProtobufMode(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/metadata.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `import "mcp/options/v1/options.proto";`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/metadata;metadatav1";`, + `message MetadataRequest {}`, + `message MetadataResponse {}`, + `service MetadataAPI {`, + ` rpc ReadThing(MetadataRequest) returns (MetadataResponse) {`, + ` option (mcp.options.v1.method) = {`, + ` annotations: {`, + ` read_only_hint: true`, + ` destructive_hint: false`, + ` idempotent_hint: true`, + ` open_world_hint: false`, + ` }`, + ` icons: [{`, + ` src: "https://example.com/method.png"`, + ` mime_type: "image/png"`, + ` sizes: "32x32"`, + ` theme: "dark"`, + ` }]`, + ` execution: { task_support: TASK_SUPPORT_REQUIRED }`, + ` };`, + ` }`, + `}`, + ``, + }, "\n"), + }, "test/v1/metadata.proto") + + file := plugin.FilesByPath["test/v1/metadata.proto"] + if file == nil { + t.Fatal("metadata proto file not found in plugin") + } + + model, err := CollectFileModel(file, Options{ + Language: LanguagePython, + PythonRuntime: PythonRuntimeGoogleProtobuf, + PythonHandler: PythonHandlerProtobuf, + }) + if err != nil { + t.Fatalf("CollectFileModel: %v", err) + } + if err := renderPythonFile(plugin, model); err != nil { + t.Fatalf("renderPythonFile: %v", err) + } + + generated := string(generatedFileContent(t, plugin, "test/v1/metadata_mcp.py")) + wantSnippets := []string{ + "def read_thing(self, ctx: ToolRequestContext, req: metadata_pb2.MetadataRequest) -> metadata_pb2.MetadataResponse | Awaitable[metadata_pb2.MetadataResponse]:", + "from_pb=_identity,", + "to_pb=_identity,", + `annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}`, + `icons=[{"src": "https://example.com/method.png", "mimeType": "image/png", "sizes": ["32x32"], "theme": "dark"}]`, + `execution={"taskSupport": "required"}`, + } + for _, snippet := range wantSnippets { + if !strings.Contains(generated, snippet) { + t.Fatalf("generated python missing protobuf metadata snippet %q\n%s", snippet, generated) + } + } +} + func TestPythonAndGoRenderersShareContractModel(t *testing.T) { plugin := newExampleProtogenPlugin(t) file := plugin.FilesByPath["internal/testproto/example/v1/example.proto"] diff --git a/internal/codegen/python_protobuf_runtime_test.go b/internal/codegen/python_protobuf_runtime_test.go index 880f9a1..33d3b14 100644 --- a/internal/codegen/python_protobuf_runtime_test.go +++ b/internal/codegen/python_protobuf_runtime_test.go @@ -640,3 +640,46 @@ else: raise AssertionError("expected duplicate registration to fail") `) } + +func TestPythonProtobufRuntime_NamespaceAndMetadataParity(t *testing.T) { + runExamplePythonProtobufScript(t, ` +expected_icons = [{ + "src": "https://example.com/method.png", + "mimeType": "image/png", + "sizes": ["32x32"], + "theme": "dark", +}] + +tool = module._build_tool(module._RegisteredTool( + name="raw_pb_CreateReport", + title="Create report", + description="Create a report for a city.", + input_schema_json=module.EXAMPLE_API_CREATE_REPORT_INPUT_SCHEMA_JSON, + output_schema_json=module.EXAMPLE_API_CREATE_REPORT_OUTPUT_SCHEMA_JSON, + request_type=example_pb2.CreateReportRequest, + response_type=example_pb2.CreateReportResponse, + from_pb=module._identity, + to_pb=module._identity, + handler=lambda _ctx, _req: example_pb2.CreateReportResponse(), + annotations={ + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + icons=expected_icons, + execution={"taskSupport": "required"}, +)) + +assert tool.name == "raw_pb_CreateReport" +annotations = tool.annotations.model_dump(by_alias=True, exclude_none=True) +assert annotations["readOnlyHint"] is True +assert annotations["destructiveHint"] is False +assert annotations["idempotentHint"] is True +assert annotations["openWorldHint"] is False +icons = [icon.model_dump(by_alias=True, exclude_none=True) for icon in tool.icons] +assert icons == expected_icons +execution = tool.execution.model_dump(by_alias=True, exclude_none=True) +assert execution["taskSupport"] == "required" +`) +} From 2e5baf465a03c05bb69c78445ebe4a8e9c7d066f Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 17:56:45 +0300 Subject: [PATCH 68/74] feat(15-01): add protobuf Python standalone example --- .../10_python_protobuf_standalone/.gitignore | 5 + .../10_python_protobuf_standalone/Makefile | 31 ++ .../10_python_protobuf_standalone/README.md | 76 ++++ .../10_python_protobuf_standalone/easyp.lock | 1 + .../10_python_protobuf_standalone/easyp.yaml | 38 ++ .../mcp/__init__.py | 5 + .../mcp/options/v1/options_pb2.py | 68 ++++ .../proto/__init__.py | 1 + .../proto/tasks.proto | 135 +++++++ .../proto/tasks_mcp.py | 329 ++++++++++++++++++ .../proto/tasks_pb2.py | 82 +++++ .../pyproject.toml | 22 ++ .../10_python_protobuf_standalone/server.py | 92 +++++ examples/Makefile | 2 + examples/python_stdio_test.go | 105 ++++++ 15 files changed, 992 insertions(+) create mode 100644 examples/10_python_protobuf_standalone/.gitignore create mode 100644 examples/10_python_protobuf_standalone/Makefile create mode 100644 examples/10_python_protobuf_standalone/README.md create mode 100644 examples/10_python_protobuf_standalone/easyp.lock create mode 100644 examples/10_python_protobuf_standalone/easyp.yaml create mode 100644 examples/10_python_protobuf_standalone/mcp/__init__.py create mode 100644 examples/10_python_protobuf_standalone/mcp/options/v1/options_pb2.py create mode 100644 examples/10_python_protobuf_standalone/proto/__init__.py create mode 100644 examples/10_python_protobuf_standalone/proto/tasks.proto create mode 100644 examples/10_python_protobuf_standalone/proto/tasks_mcp.py create mode 100644 examples/10_python_protobuf_standalone/proto/tasks_pb2.py create mode 100644 examples/10_python_protobuf_standalone/pyproject.toml create mode 100644 examples/10_python_protobuf_standalone/server.py diff --git a/examples/10_python_protobuf_standalone/.gitignore b/examples/10_python_protobuf_standalone/.gitignore new file mode 100644 index 0000000..4160acd --- /dev/null +++ b/examples/10_python_protobuf_standalone/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +google/ diff --git a/examples/10_python_protobuf_standalone/Makefile b/examples/10_python_protobuf_standalone/Makefile new file mode 100644 index 0000000..1974825 --- /dev/null +++ b/examples/10_python_protobuf_standalone/Makefile @@ -0,0 +1,31 @@ +.PHONY: setup generate lint run clean + +PYTHON ?= python3 +VENV ?= .venv +VENV_PYTHON := $(VENV)/bin/python +VENV_STAMP := $(VENV)/.installed + +$(VENV_PYTHON): + $(PYTHON) -m venv $(VENV) + +$(VENV_STAMP): pyproject.toml | $(VENV_PYTHON) + $(VENV_PYTHON) -m pip install --upgrade pip + $(VENV_PYTHON) -m pip install -e . + touch $(VENV_STAMP) + +setup: $(VENV_STAMP) + +generate: + easyp --cfg easyp.yaml mod download + easyp --cfg easyp.yaml generate -p . -r . + rm -rf google + +lint: + easyp --cfg easyp.yaml mod download + easyp --cfg easyp.yaml lint -p . -r . + +run: setup + $(VENV_PYTHON) server.py + +clean: + rm -rf .venv google diff --git a/examples/10_python_protobuf_standalone/README.md b/examples/10_python_protobuf_standalone/README.md new file mode 100644 index 0000000..8277824 --- /dev/null +++ b/examples/10_python_protobuf_standalone/README.md @@ -0,0 +1,76 @@ +# Standalone Python MCP Server With Raw Protobuf Handlers + +This example is structured like a user-owned Python project and opts into +`python_handler: protobuf`. Generated handlers receive and return raw +`tasks_pb2.*` message classes instead of generated dataclasses. + +Use this layout when you already have Python server code written against +standard `google.protobuf` generated classes. If you want the default +dataclass API with `UNSET` and explicit `oneof` wrappers, use +[`../5_python_standalone`](../5_python_standalone/) instead. + +## Generate + +```bash +make generate +``` + +The key generator option is: + +```yaml +lang: python +python_runtime: google.protobuf +python_handler: protobuf +``` + +That generates: + +- `proto/tasks_pb2.py` +- `proto/tasks_mcp.py` +- `proto/__init__.py` +- `mcp/__init__.py` +- `mcp/options/v1/options_pb2.py` + +The generated MCP sidecar still exposes the normal registration helper: + +```python +from proto import tasks_mcp, tasks_pb2 + + +class TaskStore(tasks_mcp.TaskAPIToolHandler): + def create_task( + self, + _ctx: tasks_mcp.ToolRequestContext, + req: tasks_pb2.CreateTaskRequest, + ) -> tasks_pb2.CreateTaskResponse: + ... + + +tasks_mcp.register_task_api_tools(server, TaskStore()) +``` + +In protobuf mode, generated dataclasses, `UNSET`, and dataclass mapper helpers +are omitted. Runtime validation, ProtoJSON parsing, output validation, +structured content, tool names, annotations, icons, and execution metadata are +still owned by the generated MCP sidecar. + +## Run + +```bash +make setup +make run +``` + +The server exposes: + +- `tasks_CreateTask` +- `tasks_ListTasks` +- `tasks_Health` + +The checked-in `easyp.yaml` runs the local plugin from this repository so the +example always exercises the current source tree. In an external project, +replace that command with a released version such as: + +```yaml +command: ["go", "run", "github.com/easyp-tech/protoc-gen-mcp/cmd/protoc-gen-mcp@latest"] +``` diff --git a/examples/10_python_protobuf_standalone/easyp.lock b/examples/10_python_protobuf_standalone/easyp.lock new file mode 100644 index 0000000..c30dd0d --- /dev/null +++ b/examples/10_python_protobuf_standalone/easyp.lock @@ -0,0 +1 @@ +github.com/easyp-tech/protoc-gen-mcp v0.4.0^{} h1:89szb16JOQw4+3VcouZEvsW8Kh6XMZi8kntiD8OOvcI= diff --git a/examples/10_python_protobuf_standalone/easyp.yaml b/examples/10_python_protobuf_standalone/easyp.yaml new file mode 100644 index 0000000..be722b8 --- /dev/null +++ b/examples/10_python_protobuf_standalone/easyp.yaml @@ -0,0 +1,38 @@ +deps: + - github.com/easyp-tech/protoc-gen-mcp + +lint: + use: + - PACKAGE_DEFINED + - PACKAGE_LOWER_SNAKE_CASE + - PACKAGE_VERSION_SUFFIX + - FILE_LOWER_SNAKE_CASE + - MESSAGE_PASCAL_CASE + - FIELD_LOWER_SNAKE_CASE + - RPC_PASCAL_CASE + - SERVICE_PASCAL_CASE + - SERVICE_SUFFIX + - RPC_REQUEST_RESPONSE_UNIQUE + - RPC_REQUEST_STANDARD_NAME + - RPC_RESPONSE_STANDARD_NAME + - RPC_NO_CLIENT_STREAMING + - RPC_NO_SERVER_STREAMING + + service_suffix: API + +generate: + inputs: + - directory: + path: proto + root: "." + plugins: + - name: python + with_imports: true + out: . + - command: ["go", "run", "../../cmd/protoc-gen-mcp"] + out: . + opts: + paths: source_relative + lang: python + python_runtime: google.protobuf + python_handler: protobuf diff --git a/examples/10_python_protobuf_standalone/mcp/__init__.py b/examples/10_python_protobuf_standalone/mcp/__init__.py new file mode 100644 index 0000000..813691e --- /dev/null +++ b/examples/10_python_protobuf_standalone/mcp/__init__.py @@ -0,0 +1,5 @@ +# Code generated by protoc-gen-mcp. DO NOT EDIT. +"""Bridge generated mcp.options.* modules with the official MCP SDK package.""" +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) diff --git a/examples/10_python_protobuf_standalone/mcp/options/v1/options_pb2.py b/examples/10_python_protobuf_standalone/mcp/options/v1/options_pb2.py new file mode 100644 index 0000000..fe8a59f --- /dev/null +++ b/examples/10_python_protobuf_standalone/mcp/options/v1/options_pb2.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: mcp/options/v1/options.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'mcp/options/v1/options.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cmcp/options/v1/options.proto\x12\x0emcp.options.v1\x1a google/protobuf/descriptor.proto\"|\n\x0eServiceOptions\x12\x1c\n\tnamespace\x18\x01 \x01(\tR\tnamespace\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12*\n\x05icons\x18\x03 \x03(\x0b\x32\x14.mcp.options.v1.IconR\x05icons\"\xa2\x02\n\rMethodOptions\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n\x05title\x18\x02 \x01(\tR\x05title\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12\x16\n\x06hidden\x18\x04 \x01(\x08R\x06hidden\x12\x41\n\x0b\x61nnotations\x18\n \x01(\x0b\x32\x1f.mcp.options.v1.ToolAnnotationsR\x0b\x61nnotations\x12*\n\x05icons\x18\x0b \x03(\x0b\x32\x14.mcp.options.v1.IconR\x05icons\x12>\n\texecution\x18\x0c \x01(\x0b\x32 .mcp.options.v1.ExecutionOptionsR\texecution\"\x81\x06\n\x0c\x46ieldOptions\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x38\n\x08\x65xamples\x18\x03 \x03(\x0b\x32\x1c.mcp.options.v1.ExampleValueR\x08\x65xamples\x12\x41\n\rdefault_value\x18\x04 \x01(\x0b\x32\x1c.mcp.options.v1.ExampleValueR\x0c\x64\x65\x66\x61ultValue\x12\x18\n\x07pattern\x18\n \x01(\tR\x07pattern\x12\x16\n\x06\x66ormat\x18\x0b \x01(\tR\x06\x66ormat\x12\"\n\nmin_length\x18\x0c \x01(\rH\x00R\tminLength\x88\x01\x01\x12\"\n\nmax_length\x18\r \x01(\rH\x01R\tmaxLength\x88\x01\x01\x12\x1d\n\x07minimum\x18\x14 \x01(\x01H\x02R\x07minimum\x88\x01\x01\x12\x1d\n\x07maximum\x18\x15 \x01(\x01H\x03R\x07maximum\x88\x01\x01\x12\x30\n\x11\x65xclusive_minimum\x18\x16 \x01(\x01H\x04R\x10\x65xclusiveMinimum\x88\x01\x01\x12\x30\n\x11\x65xclusive_maximum\x18\x17 \x01(\x01H\x05R\x10\x65xclusiveMaximum\x88\x01\x01\x12$\n\x0bmultiple_of\x18\x18 \x01(\x01H\x06R\nmultipleOf\x88\x01\x01\x12 \n\tmin_items\x18\x1e \x01(\rH\x07R\x08minItems\x88\x01\x01\x12 \n\tmax_items\x18\x1f \x01(\rH\x08R\x08maxItems\x88\x01\x01\x12!\n\x0cunique_items\x18 \x01(\x08R\x0buniqueItems\x12\x1b\n\tread_only\x18( \x01(\x08R\x08readOnlyB\r\n\x0b_min_lengthB\r\n\x0b_max_lengthB\n\n\x08_minimumB\n\n\x08_maximumB\x14\n\x12_exclusive_minimumB\x14\n\x12_exclusive_maximumB\x0e\n\x0c_multiple_ofB\x0c\n\n_min_itemsB\x0c\n\n_max_items\"\xce\x02\n\x0c\x45xampleValue\x12#\n\x0cstring_value\x18\x01 \x01(\tH\x00R\x0bstringValue\x12#\n\x0cnumber_value\x18\x02 \x01(\x01H\x00R\x0bnumberValue\x12\x1f\n\nbool_value\x18\x03 \x01(\x08H\x00R\tboolValue\x12\x42\n\x0cobject_value\x18\x04 \x01(\x0b\x32\x1d.mcp.options.v1.ExampleObjectH\x00R\x0bobjectValue\x12?\n\x0b\x61rray_value\x18\x05 \x01(\x0b\x32\x1c.mcp.options.v1.ExampleArrayH\x00R\narrayValue\x12\x1f\n\nnull_value\x18\x06 \x01(\x08H\x00R\tnullValue\x12%\n\rinteger_value\x18\x07 \x01(\x03H\x00R\x0cintegerValueB\x06\n\x04kind\"\xbb\x01\n\rExampleObject\x12M\n\nproperties\x18\x01 \x03(\x0b\x32-.mcp.options.v1.ExampleObject.PropertiesEntryR\nproperties\x1a[\n\x0fPropertiesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32\x1c.mcp.options.v1.ExampleValueR\x05value:\x02\x38\x01\"B\n\x0c\x45xampleArray\x12\x32\n\x05items\x18\x01 \x03(\x0b\x32\x1c.mcp.options.v1.ExampleValueR\x05items\"\xfc\x01\n\x0fToolAnnotations\x12$\n\x0eread_only_hint\x18\x01 \x01(\x08R\x0creadOnlyHint\x12.\n\x10\x64\x65structive_hint\x18\x02 \x01(\x08H\x00R\x0f\x64\x65structiveHint\x88\x01\x01\x12\'\n\x0fidempotent_hint\x18\x03 \x01(\x08R\x0eidempotentHint\x12+\n\x0fopen_world_hint\x18\x04 \x01(\x08H\x01R\ropenWorldHint\x88\x01\x01\x12\x14\n\x05title\x18\x05 \x01(\tR\x05titleB\x13\n\x11_destructive_hintB\x12\n\x10_open_world_hint\"a\n\x04Icon\x12\x10\n\x03src\x18\x01 \x01(\tR\x03src\x12\x1b\n\tmime_type\x18\x02 \x01(\tR\x08mimeType\x12\x14\n\x05sizes\x18\x03 \x03(\tR\x05sizes\x12\x14\n\x05theme\x18\x04 \x01(\tR\x05theme\"R\n\x10\x45xecutionOptions\x12>\n\x0ctask_support\x18\x01 \x01(\x0e\x32\x1b.mcp.options.v1.TaskSupportR\x0btaskSupport\"L\n\x0cOneofOptions\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x1a\n\x08required\x18\x02 \x01(\x08R\x08required\"\x83\x01\n\x0eMessageOptions\x12\x14\n\x05title\x18\x01 \x01(\tR\x05title\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\x12\x39\n\x08\x65xamples\x18\x03 \x03(\x0b\x32\x1d.mcp.options.v1.ExampleObjectR\x08\x65xamples\"E\n\x0b\x45numOptions\x12\x14\n\x05title\x18\x01 \x01(\tR\x05title\x12 \n\x0b\x64\x65scription\x18\x02 \x01(\tR\x0b\x64\x65scription\"L\n\x10\x45numValueOptions\x12 \n\x0b\x64\x65scription\x18\x01 \x01(\tR\x0b\x64\x65scription\x12\x16\n\x06hidden\x18\x02 \x01(\x08R\x06hidden*Z\n\x0bTaskSupport\x12\x15\n\x11TASK_SUPPORT_NONE\x10\x00\x12\x19\n\x15TASK_SUPPORT_OPTIONAL\x10\x01\x12\x19\n\x15TASK_SUPPORT_REQUIRED\x10\x02:[\n\x07service\x12\x1f.google.protobuf.ServiceOptions\x18\xf9\xc6\x05 \x01(\x0b\x32\x1e.mcp.options.v1.ServiceOptionsR\x07service:W\n\x06method\x12\x1e.google.protobuf.MethodOptions\x18\xfa\xc6\x05 \x01(\x0b\x32\x1d.mcp.options.v1.MethodOptionsR\x06method:S\n\x05\x66ield\x12\x1d.google.protobuf.FieldOptions\x18\xfb\xc6\x05 \x01(\x0b\x32\x1c.mcp.options.v1.FieldOptionsR\x05\x66ield:[\n\x07message\x12\x1f.google.protobuf.MessageOptions\x18\xfc\xc6\x05 \x01(\x0b\x32\x1e.mcp.options.v1.MessageOptionsR\x07message:O\n\x04\x65num\x12\x1c.google.protobuf.EnumOptions\x18\xfd\xc6\x05 \x01(\x0b\x32\x1b.mcp.options.v1.EnumOptionsR\x04\x65num:d\n\nenum_value\x12!.google.protobuf.EnumValueOptions\x18\xfe\xc6\x05 \x01(\x0b\x32 .mcp.options.v1.EnumValueOptionsR\tenumValue:S\n\x05oneof\x12\x1d.google.protobuf.OneofOptions\x18\xff\xc6\x05 \x01(\x0b\x32\x1c.mcp.options.v1.OneofOptionsR\x05oneofB?Z=github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1;optionsv1b\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'mcp.options.v1.options_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'Z=github.com/easyp-tech/protoc-gen-mcp/mcp/options/v1;optionsv1' + _globals['_EXAMPLEOBJECT_PROPERTIESENTRY']._loaded_options = None + _globals['_EXAMPLEOBJECT_PROPERTIESENTRY']._serialized_options = b'8\001' + _globals['_TASKSUPPORT']._serialized_start=2667 + _globals['_TASKSUPPORT']._serialized_end=2757 + _globals['_SERVICEOPTIONS']._serialized_start=82 + _globals['_SERVICEOPTIONS']._serialized_end=206 + _globals['_METHODOPTIONS']._serialized_start=209 + _globals['_METHODOPTIONS']._serialized_end=499 + _globals['_FIELDOPTIONS']._serialized_start=502 + _globals['_FIELDOPTIONS']._serialized_end=1271 + _globals['_EXAMPLEVALUE']._serialized_start=1274 + _globals['_EXAMPLEVALUE']._serialized_end=1608 + _globals['_EXAMPLEOBJECT']._serialized_start=1611 + _globals['_EXAMPLEOBJECT']._serialized_end=1798 + _globals['_EXAMPLEOBJECT_PROPERTIESENTRY']._serialized_start=1707 + _globals['_EXAMPLEOBJECT_PROPERTIESENTRY']._serialized_end=1798 + _globals['_EXAMPLEARRAY']._serialized_start=1800 + _globals['_EXAMPLEARRAY']._serialized_end=1866 + _globals['_TOOLANNOTATIONS']._serialized_start=1869 + _globals['_TOOLANNOTATIONS']._serialized_end=2121 + _globals['_ICON']._serialized_start=2123 + _globals['_ICON']._serialized_end=2220 + _globals['_EXECUTIONOPTIONS']._serialized_start=2222 + _globals['_EXECUTIONOPTIONS']._serialized_end=2304 + _globals['_ONEOFOPTIONS']._serialized_start=2306 + _globals['_ONEOFOPTIONS']._serialized_end=2382 + _globals['_MESSAGEOPTIONS']._serialized_start=2385 + _globals['_MESSAGEOPTIONS']._serialized_end=2516 + _globals['_ENUMOPTIONS']._serialized_start=2518 + _globals['_ENUMOPTIONS']._serialized_end=2587 + _globals['_ENUMVALUEOPTIONS']._serialized_start=2589 + _globals['_ENUMVALUEOPTIONS']._serialized_end=2665 +# @@protoc_insertion_point(module_scope) diff --git a/examples/10_python_protobuf_standalone/proto/__init__.py b/examples/10_python_protobuf_standalone/proto/__init__.py new file mode 100644 index 0000000..6e8ced9 --- /dev/null +++ b/examples/10_python_protobuf_standalone/proto/__init__.py @@ -0,0 +1 @@ +# Code generated by protoc-gen-mcp. DO NOT EDIT. diff --git a/examples/10_python_protobuf_standalone/proto/tasks.proto b/examples/10_python_protobuf_standalone/proto/tasks.proto new file mode 100644 index 0000000..cea95d6 --- /dev/null +++ b/examples/10_python_protobuf_standalone/proto/tasks.proto @@ -0,0 +1,135 @@ +syntax = "proto3"; + +package tasks.v1; + +import "google/protobuf/timestamp.proto"; +import "mcp/options/v1/options.proto"; + +service TaskAPI { + option (mcp.options.v1.service) = { + namespace: "tasks" + description: "A small in-memory task service for raw protobuf Python MCP handlers." + }; + + rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse) { + option (mcp.options.v1.method) = { + title: "Create task" + description: "Create a task in the local in-memory task list." + annotations: { + destructive_hint: false + idempotent_hint: false + open_world_hint: false + } + }; + } + + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse) { + option (mcp.options.v1.method) = { + title: "List tasks" + description: "List tasks by completion state and tags." + annotations: { + read_only_hint: true + open_world_hint: false + } + }; + } + + rpc Health(HealthRequest) returns (HealthResponse) { + option (mcp.options.v1.method) = { + title: "Task server health" + description: "Verify that the raw protobuf task MCP server is alive." + annotations: { + read_only_hint: true + open_world_hint: false + } + }; + } +} + +message Task { + string id = 1 [(mcp.options.v1.field) = { + read_only: true + description: "Server-assigned task identifier." + }]; + + string title = 2 [(mcp.options.v1.field) = { + description: "Short human-readable task title." + min_length: 1 + max_length: 120 + examples: [{ string_value: "Ship protobuf handler docs" }] + }]; + + bool completed = 3 [(mcp.options.v1.field) = { + description: "Whether the task has been completed." + }]; + + repeated string tags = 4 [(mcp.options.v1.field) = { + description: "Optional tags used for filtering." + unique_items: true + examples: [ + { array_value: { items: [{ string_value: "python" }, { string_value: "protobuf" }] } } + ] + }]; + + google.protobuf.Timestamp created_at = 5 [(mcp.options.v1.field) = { + read_only: true + description: "Server-assigned creation timestamp." + }]; +} + +message CreateTaskRequest { + string title = 1 [(mcp.options.v1.field) = { + description: "Short human-readable task title." + min_length: 1 + max_length: 120 + examples: [{ string_value: "Ship protobuf handler docs" }] + }]; + + repeated string tags = 2 [(mcp.options.v1.field) = { + description: "Optional tags used for filtering." + unique_items: true + examples: [ + { array_value: { items: [{ string_value: "python" }, { string_value: "protobuf" }] } } + ] + }]; +} + +message CreateTaskResponse { + Task task = 1; +} + +message ListTasksRequest { + optional bool include_completed = 1 [(mcp.options.v1.field) = { + description: "When false, completed tasks are excluded." + default_value: { bool_value: true } + }]; + + repeated string tags = 2 [(mcp.options.v1.field) = { + description: "Optional tags; a task must have every requested tag." + unique_items: true + examples: [ + { array_value: { items: [{ string_value: "protobuf" }] } } + ] + }]; + + optional int32 limit = 3 [(mcp.options.v1.field) = { + description: "Maximum number of tasks to return." + minimum: 1.0 + maximum: 50.0 + default_value: { integer_value: 10 } + }]; +} + +message ListTasksResponse { + repeated Task tasks = 1; +} + +message HealthRequest {} + +message HealthResponse { + bool ok = 1; + int32 task_count = 2 [(mcp.options.v1.field) = { + read_only: true + description: "Current number of tasks in memory." + }]; +} diff --git a/examples/10_python_protobuf_standalone/proto/tasks_mcp.py b/examples/10_python_protobuf_standalone/proto/tasks_mcp.py new file mode 100644 index 0000000..c4179b1 --- /dev/null +++ b/examples/10_python_protobuf_standalone/proto/tasks_mcp.py @@ -0,0 +1,329 @@ +# Code generated by protoc-gen-mcp. DO NOT EDIT. +# source: proto/tasks.proto +from __future__ import annotations + +import enum +import inspect +import json +import weakref +from dataclasses import dataclass, field +from typing import Any, Awaitable, Protocol, TypeAlias + +import jsonschema +import mcp.server.lowlevel +import mcp.server.session +import mcp.shared.exceptions +import mcp.types +from google.protobuf import any_pb2, duration_pb2, empty_pb2, field_mask_pb2, json_format, struct_pb2, timestamp_pb2, wrappers_pb2 +try: + from . import tasks_pb2 +except ImportError: + import tasks_pb2 + +ToolRequestContext = mcp.server.session.ServerSession + +@dataclass(frozen=True) +class _RegisteredTool: + name: str + title: str + description: str + input_schema_json: str + output_schema_json: str + request_type: type[Any] + response_type: type[Any] + from_pb: Any + to_pb: Any + handler: Any + annotations: dict[str, Any] | None + icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None + +class _ServerToolRegistry: + def __init__(self, server: mcp.server.lowlevel.Server) -> None: + self.server = server + self.tools: dict[str, _RegisteredTool] = {} + self.reserved_tool_names = _get_reserved_tool_names(server) + self.handlers_installed = False + self.previous_list_tools: Any | None = None + self.previous_call_tool: Any | None = None + + def add_tool(self, tool: _RegisteredTool) -> None: + if tool.name in self.reserved_tool_names: + raise ValueError(f"duplicate tool registration: {tool.name}") + self.tools[tool.name] = tool + self.reserved_tool_names.add(tool.name) + + def list_tools(self) -> list[mcp.types.Tool]: + return [_build_tool(tool) for tool in self.tools.values()] + + async def call_tool(self, name: str, arguments: dict[str, Any] | None, context: ToolRequestContext | None = None) -> mcp.types.CallToolResult: + return await _dispatch_call(self, name, arguments or {}, context) + +_SERVER_REGISTRIES: weakref.WeakKeyDictionary[mcp.server.lowlevel.Server, _ServerToolRegistry] = weakref.WeakKeyDictionary() + +def _load_schema(schema_json: str) -> dict[str, Any]: + return json.loads(schema_json) + +def _protojson_to_message(arguments: dict[str, Any], message: Any) -> Any: + json_format.ParseDict(arguments, message) + return message + +def _identity(value: Any) -> Any: + return value + +def _normalize_tool_segment(segment: str | None) -> str: + if segment is None: + return "" + parts = [part for part in segment.strip().replace(".", "_").split("_") if part] + return "_".join(parts) + +def _normalize_namespace(namespace: str | None, default: str) -> str: + if namespace is None: + namespace = default + return _normalize_tool_segment(namespace) + +def _tool_name(namespace: str, method_name: str) -> str: + namespace = _normalize_tool_segment(namespace) + method_name = _normalize_tool_segment(method_name) + if namespace == '': + return method_name + if method_name == '': + return namespace + return f"{namespace}_{method_name}" + +def _get_registry(server: mcp.server.lowlevel.Server) -> _ServerToolRegistry: + registry = _SERVER_REGISTRIES.get(server) + if registry is None: + registry = _ServerToolRegistry(server) + _SERVER_REGISTRIES[server] = registry + return registry + +_RESERVED_TOOL_NAMES_ATTR = "_protoc_gen_mcp_reserved_tool_names" + +def _get_reserved_tool_names(server: mcp.server.lowlevel.Server) -> set[str]: + reserved = getattr(server, _RESERVED_TOOL_NAMES_ATTR, None) + if reserved is None: + reserved = set() + setattr(server, _RESERVED_TOOL_NAMES_ATTR, reserved) + return reserved + +def _build_list_tools_request(request: Any) -> mcp.types.ListToolsRequest: + if isinstance(request, mcp.types.ListToolsRequest): + return request + return mcp.types.ListToolsRequest() + +def _merge_tools(previous: list[mcp.types.Tool], current: list[mcp.types.Tool]) -> list[mcp.types.Tool]: + merged: list[mcp.types.Tool] = [] + names: set[str] = set() + for tool in previous: + if tool.name in names: + raise ValueError(f"duplicate tool registration: {tool.name}") + names.add(tool.name) + merged.append(tool) + for tool in current: + if tool.name in names: + raise ValueError(f"duplicate tool registration: {tool.name}") + names.add(tool.name) + merged.append(tool) + return merged + +def _install_server_handlers(server: mcp.server.lowlevel.Server) -> _ServerToolRegistry: + registry = _get_registry(server) + if registry.handlers_installed: + return registry + registry.previous_list_tools = server.request_handlers.get(mcp.types.ListToolsRequest) + registry.previous_call_tool = server.request_handlers.get(mcp.types.CallToolRequest) + + async def _list_tools(request: Any) -> mcp.types.ServerResult: + current = _SERVER_REGISTRIES.get(server) + list_request = _build_list_tools_request(request) + previous_tools: list[mcp.types.Tool] = [] + meta: dict[str, Any] | None = None + if current is not None: + if current.previous_list_tools is not None: + previous_result = await current.previous_list_tools(list_request) + if not isinstance(previous_result.root, mcp.types.ListToolsResult): + return previous_result + if getattr(list_request.params, "cursor", None) is not None: + raise ValueError("cannot compose protoc-gen-mcp tools with paginated tools/list handlers") + if previous_result.root.nextCursor is not None: + raise ValueError("cannot compose protoc-gen-mcp tools with paginated tools/list handlers") + meta = getattr(previous_result.root, "meta", None) + previous_tools = list(previous_result.root.tools) + current_tools = [] if current is None else current.list_tools() + tools = _merge_tools(previous_tools, current_tools) + server._tool_cache.clear() + for tool in tools: + server._tool_cache[tool.name] = tool + return mcp.types.ServerResult(mcp.types.ListToolsResult(tools=tools, _meta=meta)) + + async def _call_tool(request: mcp.types.CallToolRequest) -> mcp.types.ServerResult: + current = _SERVER_REGISTRIES.get(server) + if current is None: + return mcp.types.ServerResult(_tool_error_result("tool registry is not installed")) + if request.params.name not in current.tools: + if current.previous_call_tool is not None: + return await current.previous_call_tool(request) + _invalid_params_error(request.params.name, "unknown tool") + if current.previous_call_tool is not None: + await server.request_handlers[mcp.types.ListToolsRequest](mcp.types.ListToolsRequest()) + result = await current.call_tool(request.params.name, request.params.arguments or {}, server.request_context.session) + return mcp.types.ServerResult(result) + + server.request_handlers[mcp.types.ListToolsRequest] = _list_tools + server.request_handlers[mcp.types.CallToolRequest] = _call_tool + + registry.handlers_installed = True + return registry + +def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | None: + if raw is None: + return None + return mcp.types.ToolAnnotations(**raw) + +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + +def _tool_error_result(message: str) -> mcp.types.CallToolResult: + return mcp.types.CallToolResult( + content=[mcp.types.TextContent(type="text", text=message)], + isError=True, + ) + +def _invalid_params_error(tool_name: str, error: Any) -> mcp.shared.exceptions.McpError: + raise mcp.shared.exceptions.McpError( + mcp.types.ErrorData( + code=mcp.types.INVALID_PARAMS, + message=f"invalid arguments for tool {tool_name!r}: {error}", + ) + ) + +def _build_tool(tool: _RegisteredTool) -> Any: + return mcp.types.Tool( + name=tool.name, + title=tool.title, + description=tool.description, + inputSchema=_load_schema(tool.input_schema_json), + outputSchema=_load_schema(tool.output_schema_json), + annotations=_tool_annotations(tool.annotations), + icons=tool.icons, + execution=_tool_execution(tool.execution), + ) + +async def _maybe_await(result: Any) -> Any: + if inspect.isawaitable(result): + return await result + return result + +def _message_to_json(message: Any) -> str: + kwargs = {"preserving_proto_field_name": False} + try: + return json_format.MessageToJson(message, always_print_fields_with_no_presence=True, **kwargs) + except TypeError: + return json_format.MessageToJson(message, including_default_value_fields=True, **kwargs) + +async def _dispatch_call(registry: _ServerToolRegistry, name: str, arguments: dict[str, Any], context: ToolRequestContext | None) -> mcp.types.CallToolResult: + tool = registry.tools.get(name) + if tool is None: + _invalid_params_error(name, "unknown tool") + try: + jsonschema.validate(arguments, _load_schema(tool.input_schema_json)) + except jsonschema.ValidationError as error: + _invalid_params_error(name, error) + try: + request_pb = _protojson_to_message(arguments, tool.request_type()) + request_dc = tool.from_pb(request_pb) + except Exception as error: + _invalid_params_error(name, error) + try: + response_dc = await _maybe_await(tool.handler(context, request_dc)) + except Exception as error: + return _tool_error_result(str(error)) + try: + if response_dc is None: + response_pb = tool.response_type() + else: + response_pb = tool.to_pb(response_dc) + payload = json.loads(_message_to_json(response_pb)) + except Exception as error: + raise RuntimeError(f"mcpruntime: marshal output for tool {name!r}: {error}") from error + text = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + content = [mcp.types.TextContent(type="text", text=text)] + try: + jsonschema.validate(payload, _load_schema(tool.output_schema_json)) + except jsonschema.ValidationError as error: + raise RuntimeError(f"mcpruntime: validate output for tool {name!r}: {error}") from error + return mcp.types.CallToolResult(content=content, structuredContent=payload) + +class TaskAPIToolHandler(Protocol): + def create_task(self, ctx: ToolRequestContext, req: tasks_pb2.CreateTaskRequest) -> tasks_pb2.CreateTaskResponse | Awaitable[tasks_pb2.CreateTaskResponse]: + ... + def list_tasks(self, ctx: ToolRequestContext, req: tasks_pb2.ListTasksRequest) -> tasks_pb2.ListTasksResponse | Awaitable[tasks_pb2.ListTasksResponse]: + ... + def health(self, ctx: ToolRequestContext, req: tasks_pb2.HealthRequest) -> tasks_pb2.HealthResponse | Awaitable[tasks_pb2.HealthResponse]: + ... + +def register_task_api_tools(server: mcp.server.lowlevel.Server, impl: TaskAPIToolHandler, *, namespace: str | None = None) -> None: + if impl is None: + raise ValueError("register_task_api_tools: impl is nil") + registry = _install_server_handlers(server) + resolved_namespace = _normalize_namespace(namespace, "tasks") + registry.add_tool(_RegisteredTool( + name=_tool_name(resolved_namespace, "CreateTask"), + title="Create task", + description="Create a task in the local in-memory task list.", + input_schema_json=TASK_API_CREATE_TASK_INPUT_SCHEMA_JSON, + output_schema_json=TASK_API_CREATE_TASK_OUTPUT_SCHEMA_JSON, + request_type=tasks_pb2.CreateTaskRequest, + response_type=tasks_pb2.CreateTaskResponse, + from_pb=_identity, + to_pb=_identity, + handler=impl.create_task, + annotations={"destructiveHint": False, "openWorldHint": False}, + icons=None, + execution=None, + )) + registry.add_tool(_RegisteredTool( + name=_tool_name(resolved_namespace, "ListTasks"), + title="List tasks", + description="List tasks by completion state and tags.", + input_schema_json=TASK_API_LIST_TASKS_INPUT_SCHEMA_JSON, + output_schema_json=TASK_API_LIST_TASKS_OUTPUT_SCHEMA_JSON, + request_type=tasks_pb2.ListTasksRequest, + response_type=tasks_pb2.ListTasksResponse, + from_pb=_identity, + to_pb=_identity, + handler=impl.list_tasks, + annotations={"readOnlyHint": True, "openWorldHint": False}, + icons=None, + execution=None, + )) + registry.add_tool(_RegisteredTool( + name=_tool_name(resolved_namespace, "Health"), + title="Task server health", + description="Verify that the raw protobuf task MCP server is alive.", + input_schema_json=TASK_API_HEALTH_INPUT_SCHEMA_JSON, + output_schema_json=TASK_API_HEALTH_OUTPUT_SCHEMA_JSON, + request_type=tasks_pb2.HealthRequest, + response_type=tasks_pb2.HealthResponse, + from_pb=_identity, + to_pb=_identity, + handler=impl.health, + annotations={"readOnlyHint": True, "openWorldHint": False}, + icons=None, + execution=None, + )) + +TASK_API_CREATE_TASK_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"python\",\"protobuf\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable task title.\",\"examples\":[\"Ship protobuf handler docs\"],\"minLength\":1,\"maxLength\":120}},\"examples\":[{\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"title\"],\"additionalProperties\":false}" + +TASK_API_CREATE_TASK_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"task\":{\"type\":\"object\",\"properties\":{\"completed\":{\"type\":\"boolean\",\"description\":\"Whether the task has been completed.\",\"examples\":[true]},\"createdAt\":{\"type\":\"string\",\"description\":\"Server-assigned creation timestamp.\",\"readOnly\":true,\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"id\":{\"type\":\"string\",\"description\":\"Server-assigned task identifier.\",\"readOnly\":true,\"examples\":[\"example\"]},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"python\",\"protobuf\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable task title.\",\"examples\":[\"Ship protobuf handler docs\"],\"minLength\":1,\"maxLength\":120}},\"examples\":[{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"id\",\"title\",\"completed\",\"createdAt\"],\"additionalProperties\":false}},\"examples\":[{\"task\":{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}}],\"required\":[\"task\"],\"additionalProperties\":false}" + +TASK_API_LIST_TASKS_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"includeCompleted\":{\"type\":[\"boolean\",\"null\"],\"description\":\"When false, completed tasks are excluded.\",\"default\":true,\"examples\":[true]},\"limit\":{\"type\":[\"integer\",\"null\"],\"description\":\"Maximum number of tasks to return.\",\"examples\":[-1],\"minimum\":1,\"maximum\":50},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags; a task must have every requested tag.\",\"examples\":[\"example\"]},\"description\":\"Optional tags; a task must have every requested tag.\",\"examples\":[[\"protobuf\"]],\"uniqueItems\":true}},\"examples\":[{\"includeCompleted\":true,\"limit\":-1,\"tags\":[\"example\"]}],\"additionalProperties\":false}" + +TASK_API_LIST_TASKS_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"tasks\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"object\",\"properties\":{\"completed\":{\"type\":\"boolean\",\"description\":\"Whether the task has been completed.\",\"examples\":[true]},\"createdAt\":{\"type\":\"string\",\"description\":\"Server-assigned creation timestamp.\",\"readOnly\":true,\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"id\":{\"type\":\"string\",\"description\":\"Server-assigned task identifier.\",\"readOnly\":true,\"examples\":[\"example\"]},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"python\",\"protobuf\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable task title.\",\"examples\":[\"Ship protobuf handler docs\"],\"minLength\":1,\"maxLength\":120}},\"examples\":[{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"id\",\"title\",\"completed\",\"createdAt\"],\"additionalProperties\":false},\"examples\":[[{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}]]}},\"examples\":[{\"tasks\":[{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}]}],\"additionalProperties\":false}" + +TASK_API_HEALTH_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"additionalProperties\":false}" + +TASK_API_HEALTH_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"ok\":{\"type\":\"boolean\",\"examples\":[true]},\"taskCount\":{\"type\":\"integer\",\"description\":\"Current number of tasks in memory.\",\"readOnly\":true,\"examples\":[-1]}},\"examples\":[{\"ok\":true,\"taskCount\":-1}],\"required\":[\"ok\",\"taskCount\"],\"additionalProperties\":false}" diff --git a/examples/10_python_protobuf_standalone/proto/tasks_pb2.py b/examples/10_python_protobuf_standalone/proto/tasks_pb2.py new file mode 100644 index 0000000..f175085 --- /dev/null +++ b/examples/10_python_protobuf_standalone/proto/tasks_pb2.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: proto/tasks.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'proto/tasks.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 +from mcp.options.v1 import options_pb2 as mcp_dot_options_dot_v1_dot_options__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11proto/tasks.proto\x12\x08tasks.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1cmcp/options/v1/options.proto\"\xae\x03\n\x04Task\x12\x39\n\x02id\x18\x01 \x01(\tB)\xda\xb7,%\n Server-assigned task identifier.\xc0\x02\x01R\x02id\x12^\n\x05title\x18\x02 \x01(\tBH\xda\xb7,D\n Short human-readable task title.\x1a\x1c\n\x1aShip protobuf handler docs`\x01hxR\x05title\x12H\n\tcompleted\x18\x03 \x01(\x08\x42*\xda\xb7,&\n$Whether the task has been completed.R\tcompleted\x12X\n\x04tags\x18\x04 \x03(\tBD\xda\xb7,@\n!Optional tags used for filtering.\x1a\x18*\x16\n\x08\n\x06python\n\n\n\x08protobuf\x80\x02\x01R\x04tags\x12g\n\ncreated_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampB,\xda\xb7,(\n#Server-assigned creation timestamp.\xc0\x02\x01R\tcreatedAt\"\xcd\x01\n\x11\x43reateTaskRequest\x12^\n\x05title\x18\x01 \x01(\tBH\xda\xb7,D\n Short human-readable task title.\x1a\x1c\n\x1aShip protobuf handler docs`\x01hxR\x05title\x12X\n\x04tags\x18\x02 \x03(\tBD\xda\xb7,@\n!Optional tags used for filtering.\x1a\x18*\x16\n\x08\n\x06python\n\n\n\x08protobuf\x80\x02\x01R\x04tags\"8\n\x12\x43reateTaskResponse\x12\"\n\x04task\x18\x01 \x01(\x0b\x32\x0e.tasks.v1.TaskR\x04task\"\xd9\x02\n\x10ListTasksRequest\x12\x65\n\x11include_completed\x18\x01 \x01(\x08\x42\x33\xda\xb7,/\n)When false, completed tasks are excluded.\"\x02\x18\x01H\x00R\x10includeCompleted\x88\x01\x01\x12\x61\n\x04tags\x18\x02 \x03(\tBM\xda\xb7,I\n4Optional tags; a task must have every requested tag.\x1a\x0e*\x0c\n\n\n\x08protobuf\x80\x02\x01R\x04tags\x12[\n\x05limit\x18\x03 \x01(\x05\x42@\xda\xb7,<\n\"Maximum number of tasks to return.\"\x02\x38\n\xa1\x01\x00\x00\x00\x00\x00\x00\xf0?\xa9\x01\x00\x00\x00\x00\x00\x00I@H\x01R\x05limit\x88\x01\x01\x42\x14\n\x12_include_completedB\x08\n\x06_limit\"9\n\x11ListTasksResponse\x12$\n\x05tasks\x18\x01 \x03(\x0b\x32\x0e.tasks.v1.TaskR\x05tasks\"\x0f\n\rHealthRequest\"l\n\x0eHealthResponse\x12\x0e\n\x02ok\x18\x01 \x01(\x08R\x02ok\x12J\n\ntask_count\x18\x02 \x01(\x05\x42+\xda\xb7,\'\n\"Current number of tasks in memory.\xc0\x02\x01R\ttaskCount2\x8f\x04\n\x07TaskAPI\x12\x91\x01\n\nCreateTask\x12\x1b.tasks.v1.CreateTaskRequest\x1a\x1c.tasks.v1.CreateTaskResponse\"H\xd2\xb7,D\x12\x0b\x43reate task\x1a/Create a task in the local in-memory task list.R\x04\x10\x00 \x00\x12\x86\x01\n\tListTasks\x12\x1a.tasks.v1.ListTasksRequest\x1a\x1b.tasks.v1.ListTasksResponse\"@\xd2\xb7,<\x12\nList tasks\x1a(List tasks by completion state and tags.R\x04\x08\x01 \x00\x12\x93\x01\n\x06Health\x12\x17.tasks.v1.HealthRequest\x1a\x18.tasks.v1.HealthResponse\"V\xd2\xb7,R\x12\x12Task server health\x1a\x36Verify that the raw protobuf task MCP server is alive.R\x04\x08\x01 \x00\x1aQ\xca\xb7,M\n\x05tasks\x12\x44\x41 small in-memory task service for raw protobuf Python MCP handlers.b\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'proto.tasks_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_TASK'].fields_by_name['id']._loaded_options = None + _globals['_TASK'].fields_by_name['id']._serialized_options = b'\332\267,%\n Server-assigned task identifier.\300\002\001' + _globals['_TASK'].fields_by_name['title']._loaded_options = None + _globals['_TASK'].fields_by_name['title']._serialized_options = b'\332\267,D\n Short human-readable task title.\032\034\n\032Ship protobuf handler docs`\001hx' + _globals['_TASK'].fields_by_name['completed']._loaded_options = None + _globals['_TASK'].fields_by_name['completed']._serialized_options = b'\332\267,&\n$Whether the task has been completed.' + _globals['_TASK'].fields_by_name['tags']._loaded_options = None + _globals['_TASK'].fields_by_name['tags']._serialized_options = b'\332\267,@\n!Optional tags used for filtering.\032\030*\026\n\010\n\006python\n\n\n\010protobuf\200\002\001' + _globals['_TASK'].fields_by_name['created_at']._loaded_options = None + _globals['_TASK'].fields_by_name['created_at']._serialized_options = b'\332\267,(\n#Server-assigned creation timestamp.\300\002\001' + _globals['_CREATETASKREQUEST'].fields_by_name['title']._loaded_options = None + _globals['_CREATETASKREQUEST'].fields_by_name['title']._serialized_options = b'\332\267,D\n Short human-readable task title.\032\034\n\032Ship protobuf handler docs`\001hx' + _globals['_CREATETASKREQUEST'].fields_by_name['tags']._loaded_options = None + _globals['_CREATETASKREQUEST'].fields_by_name['tags']._serialized_options = b'\332\267,@\n!Optional tags used for filtering.\032\030*\026\n\010\n\006python\n\n\n\010protobuf\200\002\001' + _globals['_LISTTASKSREQUEST'].fields_by_name['include_completed']._loaded_options = None + _globals['_LISTTASKSREQUEST'].fields_by_name['include_completed']._serialized_options = b'\332\267,/\n)When false, completed tasks are excluded.\"\002\030\001' + _globals['_LISTTASKSREQUEST'].fields_by_name['tags']._loaded_options = None + _globals['_LISTTASKSREQUEST'].fields_by_name['tags']._serialized_options = b'\332\267,I\n4Optional tags; a task must have every requested tag.\032\016*\014\n\n\n\010protobuf\200\002\001' + _globals['_LISTTASKSREQUEST'].fields_by_name['limit']._loaded_options = None + _globals['_LISTTASKSREQUEST'].fields_by_name['limit']._serialized_options = b'\332\267,<\n\"Maximum number of tasks to return.\"\0028\n\241\001\000\000\000\000\000\000\360?\251\001\000\000\000\000\000\000I@' + _globals['_HEALTHRESPONSE'].fields_by_name['task_count']._loaded_options = None + _globals['_HEALTHRESPONSE'].fields_by_name['task_count']._serialized_options = b'\332\267,\'\n\"Current number of tasks in memory.\300\002\001' + _globals['_TASKAPI']._loaded_options = None + _globals['_TASKAPI']._serialized_options = b'\312\267,M\n\005tasks\022DA small in-memory task service for raw protobuf Python MCP handlers.' + _globals['_TASKAPI'].methods_by_name['CreateTask']._loaded_options = None + _globals['_TASKAPI'].methods_by_name['CreateTask']._serialized_options = b'\322\267,D\022\013Create task\032/Create a task in the local in-memory task list.R\004\020\000 \000' + _globals['_TASKAPI'].methods_by_name['ListTasks']._loaded_options = None + _globals['_TASKAPI'].methods_by_name['ListTasks']._serialized_options = b'\322\267,<\022\nList tasks\032(List tasks by completion state and tags.R\004\010\001 \000' + _globals['_TASKAPI'].methods_by_name['Health']._loaded_options = None + _globals['_TASKAPI'].methods_by_name['Health']._serialized_options = b'\322\267,R\022\022Task server health\0326Verify that the raw protobuf task MCP server is alive.R\004\010\001 \000' + _globals['_TASK']._serialized_start=95 + _globals['_TASK']._serialized_end=525 + _globals['_CREATETASKREQUEST']._serialized_start=528 + _globals['_CREATETASKREQUEST']._serialized_end=733 + _globals['_CREATETASKRESPONSE']._serialized_start=735 + _globals['_CREATETASKRESPONSE']._serialized_end=791 + _globals['_LISTTASKSREQUEST']._serialized_start=794 + _globals['_LISTTASKSREQUEST']._serialized_end=1139 + _globals['_LISTTASKSRESPONSE']._serialized_start=1141 + _globals['_LISTTASKSRESPONSE']._serialized_end=1198 + _globals['_HEALTHREQUEST']._serialized_start=1200 + _globals['_HEALTHREQUEST']._serialized_end=1215 + _globals['_HEALTHRESPONSE']._serialized_start=1217 + _globals['_HEALTHRESPONSE']._serialized_end=1325 + _globals['_TASKAPI']._serialized_start=1328 + _globals['_TASKAPI']._serialized_end=1855 +# @@protoc_insertion_point(module_scope) diff --git a/examples/10_python_protobuf_standalone/pyproject.toml b/examples/10_python_protobuf_standalone/pyproject.toml new file mode 100644 index 0000000..ba96d2e --- /dev/null +++ b/examples/10_python_protobuf_standalone/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "standalone-python-protobuf-mcp-server" +version = "0.1.0" +description = "Standalone Python MCP server using raw protobuf handlers generated by protoc-gen-mcp." +requires-python = ">=3.10" +dependencies = [ + "anyio>=4,<5", + "jsonschema>=4,<5", + "mcp>=1.27,<2", + "protobuf>=6,<7", +] + +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = [] + +[tool.pyright] +pythonVersion = "3.10" +typeCheckingMode = "basic" diff --git a/examples/10_python_protobuf_standalone/server.py b/examples/10_python_protobuf_standalone/server.py new file mode 100644 index 0000000..c905881 --- /dev/null +++ b/examples/10_python_protobuf_standalone/server.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import anyio +import mcp.server.lowlevel +import mcp.server.stdio +from google.protobuf import timestamp_pb2 + +from proto import tasks_mcp, tasks_pb2 + + +def _now_timestamp() -> timestamp_pb2.Timestamp: + timestamp = timestamp_pb2.Timestamp() + timestamp.FromDatetime(datetime.now(timezone.utc)) + return timestamp + + +class TaskStore(tasks_mcp.TaskAPIToolHandler): + def __init__(self) -> None: + self._next_id = 1 + self._tasks: dict[str, tasks_pb2.Task] = {} + + def create_task( + self, + _ctx: tasks_mcp.ToolRequestContext, + req: tasks_pb2.CreateTaskRequest, + ) -> tasks_pb2.CreateTaskResponse: + task_id = f"task-{self._next_id}" + self._next_id += 1 + + task = tasks_pb2.Task( + id=task_id, + title=req.title, + completed=False, + tags=list(req.tags), + ) + task.created_at.CopyFrom(_now_timestamp()) + self._tasks[task.id] = task + return tasks_pb2.CreateTaskResponse(task=task) + + def list_tasks( + self, + _ctx: tasks_mcp.ToolRequestContext, + req: tasks_pb2.ListTasksRequest, + ) -> tasks_pb2.ListTasksResponse: + include_completed = req.include_completed if req.HasField("include_completed") else True + limit = req.limit if req.HasField("limit") else 10 + required_tags = set(req.tags) + + matches: list[tasks_pb2.Task] = [] + for task in self._tasks.values(): + if not include_completed and task.completed: + continue + if required_tags and not required_tags.issubset(set(task.tags)): + continue + matches.append(task) + if len(matches) >= limit: + break + + return tasks_pb2.ListTasksResponse(tasks=matches) + + def health( + self, + _ctx: tasks_mcp.ToolRequestContext, + _req: tasks_pb2.HealthRequest, + ) -> tasks_pb2.HealthResponse: + return tasks_pb2.HealthResponse(ok=True, task_count=len(self._tasks)) + + +def new_server() -> mcp.server.lowlevel.Server: + server = mcp.server.lowlevel.Server("standalone-python-protobuf-tasks", version="0.1.0") + tasks_mcp.register_task_api_tools(server, TaskStore()) + return server + + +async def run_stdio_server() -> None: + server = new_server() + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +def main() -> None: + anyio.run(run_stdio_server) + + +if __name__ == "__main__": + main() diff --git a/examples/Makefile b/examples/Makefile index bf543f0..0df2208 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -10,6 +10,7 @@ generate: easyp --cfg easyp.yaml generate -p . -r . rm -rf google $(MAKE) -C 5_python_standalone generate + $(MAKE) -C 10_python_protobuf_standalone generate $(MAKE) -C 6_java_standalone generate $(MAKE) -C 7_kotlin_standalone generate $(MAKE) -C 8_typescript_standalone generate @@ -19,6 +20,7 @@ lint: easyp --cfg easyp.yaml mod download easyp --cfg easyp.yaml lint -p . -r . $(MAKE) -C 5_python_standalone lint + $(MAKE) -C 10_python_protobuf_standalone lint $(MAKE) -C 6_java_standalone lint $(MAKE) -C 7_kotlin_standalone lint $(MAKE) -C 8_typescript_standalone lint diff --git a/examples/python_stdio_test.go b/examples/python_stdio_test.go index 2280cb1..c67178b 100644 --- a/examples/python_stdio_test.go +++ b/examples/python_stdio_test.go @@ -83,6 +83,26 @@ func TestStandalonePythonExampleOverStdio(t *testing.T) { runStandaloneNotebookExample(t, session) } +func TestStandalonePythonProtobufExampleOverStdio(t *testing.T) { + root := repoRoot(t) + projectDir := filepath.Join(root, "examples/10_python_protobuf_standalone") + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{ + Name: "protoc-gen-mcp-standalone-python-protobuf-example-test-client", + Version: "v0.0.1", + }, nil) + + session, err := client.Connect(ctx, &mcp.CommandTransport{ + Command: standalonePythonExampleCommand(t, projectDir), + }, nil) + if err != nil { + t.Fatalf("client.Connect() over stdio failed: %v", err) + } + defer session.Close() + + runStandaloneTaskProtobufExample(t, session) +} + func runHelloWorldExample(t *testing.T, session *mcp.ClientSession) { t.Helper() @@ -314,6 +334,91 @@ func runStandaloneNotebookExample(t *testing.T, session *mcp.ClientSession) { } } +func runStandaloneTaskProtobufExample(t *testing.T, session *mcp.ClientSession) { + t.Helper() + + ctx := context.Background() + tools, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools() failed: %v", err) + } + findTool(t, tools.Tools, "tasks_CreateTask") + findTool(t, tools.Tools, "tasks_ListTasks") + findTool(t, tools.Tools, "tasks_Health") + + createResult, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "tasks_CreateTask", + Arguments: map[string]any{ + "title": "Ship protobuf handler docs", + "tags": []any{"python", "protobuf"}, + }, + }) + if err != nil { + t.Fatalf("CallTool(CreateTask) failed: %v", err) + } + if createResult.IsError { + t.Fatalf("CreateTask returned tool error: %+v", createResult) + } + assertTextStructuredContentMatch(t, "tasks_CreateTask", createResult) + createStructured := decodeMap(t, createResult.StructuredContent) + task, ok := createStructured["task"].(map[string]any) + if !ok { + t.Fatalf("created task has type %T, want map[string]any", createStructured["task"]) + } + if got := task["title"]; got != "Ship protobuf handler docs" { + t.Fatalf("created task.title = %v, want Ship protobuf handler docs", got) + } + if got := task["tags"]; !reflect.DeepEqual(got, []any{"python", "protobuf"}) { + t.Fatalf("created task.tags = %v, want [python protobuf]", got) + } + + listResult, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "tasks_ListTasks", + Arguments: map[string]any{ + "tags": []any{"protobuf"}, + "limit": 5, + }, + }) + if err != nil { + t.Fatalf("CallTool(ListTasks) failed: %v", err) + } + if listResult.IsError { + t.Fatalf("ListTasks returned tool error: %+v", listResult) + } + assertTextStructuredContentMatch(t, "tasks_ListTasks", listResult) + listStructured := decodeMap(t, listResult.StructuredContent) + tasks, ok := listStructured["tasks"].([]any) + if !ok || len(tasks) != 1 { + t.Fatalf("tasks = %T %v, want one matching task", listStructured["tasks"], listStructured["tasks"]) + } + listedTask, ok := tasks[0].(map[string]any) + if !ok { + t.Fatalf("tasks[0] has type %T, want map[string]any", tasks[0]) + } + if got := listedTask["title"]; got != "Ship protobuf handler docs" { + t.Fatalf("listed task.title = %v, want Ship protobuf handler docs", got) + } + + healthResult, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "tasks_Health", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool(Health) failed: %v", err) + } + if healthResult.IsError { + t.Fatalf("Health returned tool error: %+v", healthResult) + } + assertTextStructuredContentMatch(t, "tasks_Health", healthResult) + healthStructured := decodeMap(t, healthResult.StructuredContent) + if got := healthStructured["ok"]; got != true { + t.Fatalf("health.ok = %v, want true", got) + } + if got := healthStructured["taskCount"]; got != 1.0 { + t.Fatalf("health.taskCount = %v, want 1", got) + } +} + func repoRoot(t *testing.T) string { t.Helper() From 9bcc74c56b013ef9d9bf2bf298f565e203f8a761 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 18:01:28 +0300 Subject: [PATCH 69/74] docs(15-02): document protobuf Python handler mode --- AGENTS.md | 30 ++++++-- README.md | 75 +++++++++++++++---- .../10_python_protobuf_standalone/README.md | 3 +- examples/5_python_standalone/README.md | 9 +++ examples/README.md | 32 ++++++-- 5 files changed, 120 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2d1316f..d023f72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,9 +45,10 @@ architecture unless explicitly revised. - `examples`: standalone Go/Python/JVM integration projects; example directories use numeric underscore prefixes such as `1_helloworld`, `4_crm_system`, `5_python_standalone`, `6_java_standalone`, and - `7_kotlin_standalone`, `8_typescript_standalone`, and - `9_javascript_standalone`, plus the dedicated JVM workspace `examples/jvm` - and Node spike workspace `examples/node/sdk-spike` + `7_kotlin_standalone`, `8_typescript_standalone`, + `9_javascript_standalone`, and `10_python_protobuf_standalone`, plus the + dedicated JVM workspace `examples/jvm` and Node spike workspace + `examples/node/sdk-spike` - `examples/node/sdk-spike`: pinned local Node package scope for TypeScript SDK, Protobuf-ES, Ajv, package-lock-backed `npm ci`, and strict NodeNext compile checks @@ -58,6 +59,10 @@ architecture unless explicitly revised. - `examples/5_python_standalone`: Python-only user-style example with its own `pyproject.toml`, `easyp.yaml`, generated `proto`/`mcp` packages, and stdio server +- `examples/10_python_protobuf_standalone`: Python-only user-style example + with its own `pyproject.toml`, `easyp.yaml`, generated `proto`/`mcp` + packages, opt-in `python_handler=protobuf` sidecar, raw `*_pb2` handlers, + and stdio server - `examples/6_java_standalone`: Java user-style standalone project with its own Gradle build, `easyp.yaml`, protobuf contract, generated Java protobuf classes, generated `lang=java` MCP sidecar, and handwritten stdio server @@ -175,7 +180,8 @@ architecture unless explicitly revised. handler protocols using generated public dataclass types from `*_mcp.py` - Generated Python modules also support opt-in `python_handler=protobuf`, where handler protocols accept and return raw generated `*_pb2` message - classes and generated dataclass public types/mapper helpers are omitted + classes, generated dataclass public types/mapper helpers are omitted, and + registration remains `register__tools(server, impl, *, namespace=None)` - Generated Kotlin files expose `ToolHandler` - Generated Kotlin files expose `registerTools(server: Server, impl: ToolHandler, namespace: String? = null)` @@ -274,6 +280,11 @@ architecture unless explicitly revised. its own `pyproject.toml`, `easyp.yaml`, generated package `__init__.py` files, generated local `mcp.options.*` modules, and `server.py` without `sys.path` bootstrap code + - `examples/10_python_protobuf_standalone` models an external Python-only + raw `*_pb2` handler project with its own `pyproject.toml`, `easyp.yaml`, + generated package `__init__.py` files, generated local `mcp.options.*` + modules, opt-in `python_handler=protobuf` sidecar, and checked-in stdio + server - standalone examples use `examples/easyp.yaml` `deps` plus `examples/easyp.lock` to resolve `mcp/options/v1/options.proto` from `github.com/easyp-tech/protoc-gen-mcp` instead of depending on the local @@ -290,7 +301,7 @@ architecture unless explicitly revised. current-file types actually referenced by other generated files, so cross-file imports work without forcing unrelated hidden-only types into the public API surface - - dedicated `examples/` directory featuring 9 standalone integration + - dedicated `examples/` directory featuring 10 standalone integration projects spanning quickstarts to complex CRM mocks and pure Python, Java, Kotlin, TypeScript, and JavaScript user-style projects - support for `oneof` explicit requiredness through `mcp.options.v1.oneof` options @@ -392,6 +403,8 @@ architecture unless explicitly revised. for the standalone TypeScript user-project generation and compile flow - `cd examples/9_javascript_standalone && make clean build` for JavaScript consumption of compiled TypeScript target output +- `cd examples/10_python_protobuf_standalone && make lint && make clean generate` + for the raw protobuf Python standalone user-project generation flow - `go test ./examples -run 'TestStandalone(TypeScript|JavaScript)ExampleOverStdio' -count=1` for standalone Node stdio parity - `go test ./...` @@ -406,6 +419,8 @@ architecture unless explicitly revised. `go test ./examples -run TestPythonExamplesOverStdio -count=1` - Python stdio integration coverage for the Python-only standalone example: `go test ./examples -run TestStandalonePythonExampleOverStdio -count=1` + - Python stdio integration coverage for the raw protobuf standalone example: + `go test ./examples -run TestStandalonePythonProtobufExampleOverStdio -count=1` - `internal/codegen` tests build descriptor requests in-process through `protocompile`, so `go test ./...` no longer depends on `protoc` being in `PATH` @@ -443,7 +458,7 @@ architecture unless explicitly revised. - Keep `mcp/options/v1/options.proto` as the source of truth for the options package `go_package`; do not reintroduce a special Easyp override unless the package layout changes again -- Planning docs are local-only in this repository and should not be committed +- planning docs are local-only in this repository and should not be committed unless the user explicitly changes that policy. ## Commands @@ -474,6 +489,9 @@ architecture unless explicitly revised. - `gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist` - Run Python-only standalone example: - `cd examples/5_python_standalone && make setup && make run` +- Run raw protobuf Python standalone example: + - `cd examples/10_python_protobuf_standalone && make setup && make run` + - `cd examples/10_python_protobuf_standalone && make lint && make clean generate` - Build standalone Java example: - `cd examples/6_java_standalone && make build` - Build standalone Kotlin example: diff --git a/README.md b/README.md index c6635da..0f0118b 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ TypeScript output and declarations. - Java runtime targets the official `io.modelcontextprotocol.sdk:mcp` SDK - TypeScript runtime targets the official `@modelcontextprotocol/sdk` SDK, Protobuf-ES, and Ajv-backed raw JSON Schema validation -- generated Python handlers use dataclasses and explicit `oneof` wrapper types - from `*_mcp.py`; `*_pb2.py` stays an internal runtime dependency +- generated Python handlers default to dataclasses and explicit `oneof` + wrapper types from `*_mcp.py`; `python_handler=protobuf` opts into raw + `*_pb2` handler request/response classes with the same registration helper - generated Kotlin handlers implement `ToolHandler` and are registered through `registerTools(server: Server, impl: ToolHandler, namespace: String? = null)` - generated Java handlers implement nested `ToolHandler` interfaces @@ -43,8 +44,10 @@ gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compi gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist go test ./internal/examplemcp -run 'Test(Java|Kotlin).*OverStdio' -count=1 (cd examples/node/sdk-spike && npm ci && npm run typecheck && npm run build) +(cd examples/10_python_protobuf_standalone && make lint && make clean generate) (cd examples/8_typescript_standalone && make lint && make clean build) (cd examples/9_javascript_standalone && make clean build) +go test ./examples -run 'TestStandalonePython(Protobuf)?ExampleOverStdio' -count=1 go test ./examples -run 'TestStandalone(TypeScript|JavaScript)ExampleOverStdio' -count=1 go test ./... goreleaser check @@ -61,7 +64,7 @@ CI is implemented in [tests.yml](.github/workflows/tests.yml) and runs config validation, Easyp lint, Easyp generation, a generated-file freshness check, JVM compile/install gates, JVM stdio parity checks, Node compile/build gates, generated Node stdio checks through `go test ./...`, and -standalone Node example tests. Releases are implemented in +standalone Python/Node example tests. Releases are implemented in [release.yml](.github/workflows/release.yml) and use [`.goreleaser.yaml`](.goreleaser.yaml) to publish tagged builds of the `protoc-gen-mcp` binary. This repository does @@ -165,6 +168,14 @@ For the Python example server: npx -y @modelcontextprotocol/inspector python ./cmd/example-python-mcp-server/main.py ``` +For the standalone raw protobuf Python example, create the example virtualenv +first and then point the Inspector at the local server: + +```bash +(cd examples/10_python_protobuf_standalone && make setup) +(cd examples/10_python_protobuf_standalone && npx -y @modelcontextprotocol/inspector .venv/bin/python server.py) +``` + For installed JVM examples, build the scripts first and then point the Inspector at the installed application: @@ -203,6 +214,7 @@ out the - [3_file_manager](examples/3_file_manager/) - Destructive tools and schema-based string parameter constraints. - [4_crm_system](examples/4_crm_system/) - A full mock system with FieldMask partial updates, custom icons mapping, schemas nested types, and advanced array filters. - [5_python_standalone](examples/5_python_standalone/) - A Python-only user-style project with its own `pyproject.toml`, `easyp.yaml`, generated bindings, and stdio server. +- [10_python_protobuf_standalone](examples/10_python_protobuf_standalone/) - A Python-only user-style project that opts into `python_handler=protobuf` and implements handlers with raw `*_pb2` classes. - [6_java_standalone](examples/6_java_standalone/) - A Java user-style project with its own Gradle build, `easyp.yaml`, protobuf contract, and generated MCP sidecar. - [7_kotlin_standalone](examples/7_kotlin_standalone/) - A Kotlin user-style project with its own Gradle build, `easyp.yaml`, protobuf contract, and generated MCP sidecar. - [8_typescript_standalone](examples/8_typescript_standalone/) - A TypeScript user-style project with its own npm package, `tsconfig.json`, `easyp.yaml`, Protobuf-ES output, generated MCP sidecar, and stdio server. @@ -273,6 +285,9 @@ generate: paths: source_relative lang: python python_runtime: google.protobuf + # Optional: use raw *_pb2 handler request/response classes instead of + # the default generated dataclass API. + # python_handler: protobuf - command: ["go", "run", "github.com/easyp-tech/protoc-gen-mcp/cmd/protoc-gen-mcp@latest"] out: . opts: @@ -307,12 +322,14 @@ easyp --cfg easyp.yaml generate -p mcp -r . That generates language-specific protobuf output plus MCP sidecars such as `*.pb.go`, `*_pb2.py`, `*.mcp.go`, `*_mcp.py`, Protobuf-ES `_pb.ts`, and TypeScript `*_mcp.ts` files. The Python target currently supports only -`python_runtime=google.protobuf`. Generated Python output also emits a small -`mcp/__init__.py` bridge so `mcp.options.*` protobuf modules can coexist with -the official `mcp` SDK package in one import tree. No special Easyp override is -required for `mcp.options.v1`, because the package declares `go_package` -directly in `options.proto`. For reproducible builds, prefer pinning a specific -tag instead of `@latest`. +`python_runtime=google.protobuf`; handler mode defaults to +`python_handler=dataclass`, and `python_handler=protobuf` opts into raw +`*_pb2` handlers. Generated Python output also emits a small `mcp/__init__.py` +bridge so `mcp.options.*` protobuf modules can coexist with the official `mcp` +SDK package in one import tree. No special Easyp override is required for +`mcp.options.v1`, because the package declares `go_package` directly in +`options.proto`. For reproducible builds, prefer pinning a specific tag instead +of `@latest`. For JVM consumers, the language selectors are `lang=java` and `lang=kotlin`. The Java path generates a Java sidecar alongside protobuf Java output. The @@ -346,13 +363,41 @@ For Python-only projects, omit the Go plugins and keep only the standard protos do not need a `go_package` option just to satisfy the generator; the plugin synthesizes internal Go package metadata before building the descriptor model. See [examples/5_python_standalone](examples/5_python_standalone/) for a -complete Python-only project with its own virtualenv setup. +complete default dataclass-mode Python project with its own virtualenv setup. + +The default Python handler mode is `python_handler=dataclass`: implement +against the dataclasses from `*_mcp.py`, not raw protobuf classes from +`*_pb2.py`. The generated Python runtime maps MCP JSON -> ProtoJSON -> `pb2` -> +dataclasses before calling your handler, then maps your dataclass response back +through protobuf serialization and output-schema validation. + +If you already have server logic written against standard protobuf classes, add +`python_handler: protobuf` to the Python MCP plugin options. In that mode the +same generated registration helper accepts handlers typed with raw `*_pb2` +messages, while schemas, ProtoJSON parsing, validation, annotations, and +structured output stay generator-owned: + +```python +import mcp.server.lowlevel +from proto import tasks_mcp, tasks_pb2 + + +class TaskStore(tasks_mcp.TaskAPIToolHandler): + def create_task( + self, + _ctx: tasks_mcp.ToolRequestContext, + req: tasks_pb2.CreateTaskRequest, + ) -> tasks_pb2.CreateTaskResponse: + ... + + +server = mcp.server.lowlevel.Server("tasks-server") +tasks_mcp.register_task_api_tools(server, TaskStore()) +``` -When you generate Python handlers, implement against the dataclasses from -`*_mcp.py`, not the raw protobuf classes from `*_pb2.py`. The generated Python -runtime maps MCP JSON -> ProtoJSON -> `pb2` -> dataclasses before calling your -handler, then maps your dataclass response back through protobuf serialization -and output-schema validation. +See +[examples/10_python_protobuf_standalone](examples/10_python_protobuf_standalone/) +for the raw `*_pb2` standalone layout. For optional fields and `oneof` groups, the handler-facing absence sentinel is `UNSET`, not `None`. JSON `null` for a schema-optional field is mapped to diff --git a/examples/10_python_protobuf_standalone/README.md b/examples/10_python_protobuf_standalone/README.md index 8277824..5141adf 100644 --- a/examples/10_python_protobuf_standalone/README.md +++ b/examples/10_python_protobuf_standalone/README.md @@ -2,7 +2,8 @@ This example is structured like a user-owned Python project and opts into `python_handler: protobuf`. Generated handlers receive and return raw -`tasks_pb2.*` message classes instead of generated dataclasses. +`*_pb2` message classes, represented here by `tasks_pb2.*`, instead of +generated dataclasses. Use this layout when you already have Python server code written against standard `google.protobuf` generated classes. If you want the default diff --git a/examples/5_python_standalone/README.md b/examples/5_python_standalone/README.md index 1639c8f..47fe0e8 100644 --- a/examples/5_python_standalone/README.md +++ b/examples/5_python_standalone/README.md @@ -53,6 +53,15 @@ The server exposes: Handlers implement generated dataclasses from `proto/notebook_mcp.py`. Raw `*_pb2.py` classes stay internal to the generated runtime. +## Handler Mode + +This example uses the default dataclass mode. Keep this mode when you want +handler-facing `UNSET`, explicit `oneof` wrappers, and generated mapper helpers +from `*_mcp.py`. If an existing server already works directly with raw `*_pb2` +classes, use +[`../10_python_protobuf_standalone`](../10_python_protobuf_standalone/) and +set `python_handler: protobuf` instead. + If you inspect this server in `@modelcontextprotocol/inspector`, note that the server forwards tool annotations exactly as declared in `proto/notebook.proto`. Inspector may render omitted hints like `destructiveHint` using its own diff --git a/examples/README.md b/examples/README.md index 8beef44..1761304 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,9 +26,9 @@ Python, Kotlin, Java, TypeScript, and JavaScript SDK paths. through the `deps` entry in `examples/easyp.yaml` and pinned by `examples/easyp.lock`, so the examples do not depend on the local repository-root options package during generation. - `5_python_standalone`, `6_java_standalone`, `7_kotlin_standalone`, and - `8_typescript_standalone` each have their own `easyp.yaml` and lockfile to - model user-owned projects. + `5_python_standalone`, `10_python_protobuf_standalone`, + `6_java_standalone`, `7_kotlin_standalone`, and `8_typescript_standalone` + each have their own `easyp.yaml` and lockfile to model user-owned projects. 2. Run any of the Go servers (e.g. `1_helloworld`): ```bash cd examples/1_helloworld @@ -45,7 +45,13 @@ Python, Kotlin, Java, TypeScript, and JavaScript SDK paths. make setup make run ``` -5. Build or run the standalone JVM projects: +5. Run the raw protobuf Python standalone project: + ```bash + cd examples/10_python_protobuf_standalone + make setup + make run + ``` +6. Build or run the standalone JVM projects: ```bash cd examples/6_java_standalone make build @@ -53,17 +59,17 @@ Python, Kotlin, Java, TypeScript, and JavaScript SDK paths. cd ../7_kotlin_standalone make build ``` -6. Run the Java/Kotlin workspace: +7. Run the Java/Kotlin workspace: Follow [`examples/jvm/README.md`](./jvm/README.md) for the tested `compileJava` / `compileKotlin`, `installDist`, and installed-script stdio flow. -7. Build or run the standalone TypeScript project: +8. Build or run the standalone TypeScript project: ```bash cd examples/8_typescript_standalone make build make run ``` -8. Build or run the standalone JavaScript consumption proof: +9. Build or run the standalone JavaScript consumption proof: ```bash cd examples/9_javascript_standalone make build @@ -109,6 +115,18 @@ protobuf contract, generated bindings, and stdio server. - **Independent environment**: `make setup` creates a local `.venv` and installs the official Python MCP SDK plus protobuf/jsonschema dependencies. - **No import hacks**: `server.py` imports generated code with `from proto import notebook_mcp` and does not edit `sys.path`. - **Python-only proto**: the user-authored proto does not need `go_package`; `protoc-gen-mcp` synthesizes internal metadata for Python generation. +- **Default dataclass mode**: handlers implement generated dataclasses from + `notebook_mcp.py`; use the raw protobuf example when existing code should + keep `*_pb2` request/response classes. + +### [10_python_protobuf_standalone](./10_python_protobuf_standalone) (Python Raw Protobuf Handler User Project) +A pure Python MCP server example that opts into `python_handler: protobuf`. +- **Raw `*_pb2` handlers**: the server imports `from proto import tasks_mcp, tasks_pb2` and implements `TaskAPIToolHandler` with `tasks_pb2.*` request/response classes. +- **Same generated registration**: `server.py` still registers tools through + `tasks_mcp.register_task_api_tools(server, TaskStore())`. +- **Generator-owned contract**: schemas, ProtoJSON parsing, validation, + annotations, structured output, and stdio behavior remain in the generated + `tasks_mcp.py` sidecar. ### [6_java_standalone](./6_java_standalone) (Java User Project) A standalone Java MCP server with its own Gradle build, `easyp.yaml`, protobuf From 4f922699f265acb64901b07d2ac566328c11695c Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 18:24:34 +0300 Subject: [PATCH 70/74] feat: support dual Python handler generation --- AGENTS.md | 12 +- README.md | 33 ++++-- .../10_python_protobuf_standalone/README.md | 6 + examples/README.md | 3 + internal/codegen/generator.go | 23 ++-- internal/codegen/generator_test.go | 106 ++++++++++++++++++ internal/codegen/options.go | 92 +++++++++++++-- internal/codegen/options_test.go | 49 ++++++++ internal/codegen/python_names.go | 11 ++ internal/codegen/render_python.go | 11 +- 10 files changed, 316 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d023f72..4e2ba4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,6 +182,10 @@ architecture unless explicitly revised. where handler protocols accept and return raw generated `*_pb2` message classes, generated dataclass public types/mapper helpers are omitted, and registration remains `register__tools(server, impl, *, namespace=None)` +- Generated Python modules support dual output with + `python_handler=dataclass+protobuf`: `*_mcp.py` remains the dataclass sidecar, + and `*_mcp_pb.py` exposes the raw protobuf sidecar with the same module-local + `ToolHandler` and `register__tools(...)` names - Generated Kotlin files expose `ToolHandler` - Generated Kotlin files expose `registerTools(server: Server, impl: ToolHandler, namespace: String? = null)` @@ -208,7 +212,7 @@ architecture unless explicitly revised. - `cmd/protoc-gen-mcp` plugin scaffold and generated `*.mcp.go` bindings - typed plugin option parsing for `lang=go|python|kotlin|java|typescript` and `python_runtime=google.protobuf|betterproto|grpclib`, plus Python-only - `python_handler=dataclass|protobuf` + `python_handler=dataclass|protobuf|dataclass+protobuf` - generated TypeScript `*_mcp.ts` sidecars for `lang=typescript`, targeting the official `@modelcontextprotocol/sdk` low-level `Server` import path and Protobuf-ES `_pb.js` modules, including typed `ToolHandler` @@ -258,6 +262,10 @@ architecture unless explicitly revised. registration uses identity converters through the existing `_RegisteredTool.from_pb/to_pb` seam, and message-only dependency files do not emit empty public `*_mcp.py` sidecars + - dual generated Python `python_handler=dataclass+protobuf` mode, where + dataclass output remains in `*_mcp.py` and raw protobuf output is emitted + to `*_mcp_pb.py`; protobuf-only `python_handler=protobuf` remains + backward-compatible and still emits raw handlers to `*_mcp.py` - generated Python `mcp/__init__.py` bridge support file so `mcp.options.*` protobuf output coexists with the official `mcp` SDK package namespace - generated Python package `__init__.py` files next to `*_mcp.py` output so @@ -482,7 +490,7 @@ architecture unless explicitly revised. - Run generated Node stdio tests: - `go test ./internal/codegen -run 'TestTypeScriptGeneratedNodeServer.*OverStdio|TestTypeScriptGeneratedNodeServerRejectsInvalid(Input|Output)OverStdio' -count=1` - Run focused Python handler option and renderer tests: - - `go test ./internal/codegen -run 'TestParseOptions|TestPythonRenderer_EmitsDataclassPublicAPI|TestPythonRenderer_EmitsProtobufHandlerPublicAPI|TestPythonRenderer_ProtobufHandlerImportsCrossFileProtobufModules|TestGenerate_PythonProtobufHandlerSkipsCrossFilePublicTypeModules' -count=1` + - `go test ./internal/codegen -run 'TestParseOptions|TestGenerate_PythonMultipleHandlers|TestPythonRenderer_EmitsDataclassPublicAPI|TestPythonRenderer_EmitsProtobufHandlerPublicAPI|TestPythonRenderer_ProtobufHandlerImportsCrossFileProtobufModules|TestGenerate_PythonProtobufHandlerSkipsCrossFilePublicTypeModules' -count=1` - Run JVM compile gate: - `gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin` - Install JVM example scripts: diff --git a/README.md b/README.md index 0f0118b..b7d1fad 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ TypeScript output and declarations. Protobuf-ES, and Ajv-backed raw JSON Schema validation - generated Python handlers default to dataclasses and explicit `oneof` wrapper types from `*_mcp.py`; `python_handler=protobuf` opts into raw - `*_pb2` handler request/response classes with the same registration helper + `*_pb2` handler request/response classes with the same registration helper; + `python_handler=dataclass+protobuf` generates both surfaces side by side - generated Kotlin handlers implement `ToolHandler` and are registered through `registerTools(server: Server, impl: ToolHandler, namespace: String? = null)` - generated Java handlers implement nested `ToolHandler` interfaces @@ -288,6 +289,9 @@ generate: # Optional: use raw *_pb2 handler request/response classes instead of # the default generated dataclass API. # python_handler: protobuf + # Optional: generate both surfaces in one pass. This keeps *_mcp.py as + # the dataclass sidecar and adds *_mcp_pb.py for raw *_pb2 handlers. + # python_handler: dataclass+protobuf - command: ["go", "run", "github.com/easyp-tech/protoc-gen-mcp/cmd/protoc-gen-mcp@latest"] out: . opts: @@ -324,12 +328,14 @@ That generates language-specific protobuf output plus MCP sidecars such as TypeScript `*_mcp.ts` files. The Python target currently supports only `python_runtime=google.protobuf`; handler mode defaults to `python_handler=dataclass`, and `python_handler=protobuf` opts into raw -`*_pb2` handlers. Generated Python output also emits a small `mcp/__init__.py` -bridge so `mcp.options.*` protobuf modules can coexist with the official `mcp` -SDK package in one import tree. No special Easyp override is required for -`mcp.options.v1`, because the package declares `go_package` directly in -`options.proto`. For reproducible builds, prefer pinning a specific tag instead -of `@latest`. +`*_pb2` handlers. `python_handler=dataclass+protobuf` generates both surfaces +in one pass: `*_mcp.py` remains the dataclass sidecar and `*_mcp_pb.py` exposes +the raw protobuf handler sidecar. Generated Python output also emits a small +`mcp/__init__.py` bridge so `mcp.options.*` protobuf modules can coexist with +the official `mcp` SDK package in one import tree. No special Easyp override is +required for `mcp.options.v1`, because the package declares `go_package` +directly in `options.proto`. For reproducible builds, prefer pinning a specific +tag instead of `@latest`. For JVM consumers, the language selectors are `lang=java` and `lang=kotlin`. The Java path generates a Java sidecar alongside protobuf Java output. The @@ -399,6 +405,19 @@ See [examples/10_python_protobuf_standalone](examples/10_python_protobuf_standalone/) for the raw `*_pb2` standalone layout. +If you want both handler surfaces from one generator invocation, use +`python_handler: dataclass+protobuf`. The dataclass API is emitted to the +normal `*_mcp.py` module, and the raw protobuf API is emitted to `*_mcp_pb.py` +with the same protocol and registration names inside that module: + +```python +from proto import tasks_mcp, tasks_mcp_pb, tasks_pb2 + +# Dataclass implementation uses tasks_mcp.TaskAPIToolHandler. +# Raw protobuf implementation uses tasks_mcp_pb.TaskAPIToolHandler. +tasks_mcp_pb.register_task_api_tools(server, RawTaskStore()) +``` + For optional fields and `oneof` groups, the handler-facing absence sentinel is `UNSET`, not `None`. JSON `null` for a schema-optional field is mapped to `UNSET` on input, and handlers should return `UNSET` when a field should remain diff --git a/examples/10_python_protobuf_standalone/README.md b/examples/10_python_protobuf_standalone/README.md index 5141adf..7c69ac0 100644 --- a/examples/10_python_protobuf_standalone/README.md +++ b/examples/10_python_protobuf_standalone/README.md @@ -24,6 +24,12 @@ python_runtime: google.protobuf python_handler: protobuf ``` +`python_handler: protobuf` is protobuf-only and writes the raw handler sidecar +to `proto/tasks_mcp.py`. If a project wants both public surfaces in one +generation pass, use `python_handler: dataclass+protobuf`; then +`proto/tasks_mcp.py` remains the dataclass sidecar and `proto/tasks_mcp_pb.py` +contains the raw `tasks_pb2.*` handler sidecar. + That generates: - `proto/tasks_pb2.py` diff --git a/examples/README.md b/examples/README.md index 1761304..9bb27dc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -124,6 +124,9 @@ A pure Python MCP server example that opts into `python_handler: protobuf`. - **Raw `*_pb2` handlers**: the server imports `from proto import tasks_mcp, tasks_pb2` and implements `TaskAPIToolHandler` with `tasks_pb2.*` request/response classes. - **Same generated registration**: `server.py` still registers tools through `tasks_mcp.register_task_api_tools(server, TaskStore())`. +- **Dual-generation option**: projects that need both surfaces can use + `python_handler: dataclass+protobuf`; that keeps dataclass output in + `*_mcp.py` and adds raw protobuf output in `*_mcp_pb.py`. - **Generator-owned contract**: schemas, ProtoJSON parsing, validation, annotations, structured output, and stdio behavior remain in the generated `tasks_mcp.py` sidecar. diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 6552b8d..9960c6e 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -25,17 +25,22 @@ func Generate(plugin *protogen.Plugin, opts Options) error { if err != nil { return err } + pythonHandlers := pythonHandlersForOptions(opts) + dualPythonHandlers := len(pythonHandlers) > 1 if err := emitPythonSupportFiles(plugin); err != nil { return err } for _, file := range orderedFiles { model := models[file.Desc.Path()] - if !pythonModelRequiresOutput(model) { - continue - } - emitPythonPackageInitFile(plugin, pythonPackageInitFiles, pythonOutputPath(file)) - if err := renderPythonFile(plugin, model); err != nil { - return err + for _, handler := range pythonHandlers { + if !pythonModelRequiresOutputForHandler(model, handler) { + continue + } + outputPath := pythonOutputPathForHandler(file, handler, dualPythonHandlers) + emitPythonPackageInitFile(plugin, pythonPackageInitFiles, outputPath) + if err := renderPythonFileForHandler(plugin, model, handler, outputPath); err != nil { + return err + } } } return nil @@ -249,10 +254,14 @@ func collectPythonModels(plugin *protogen.Plugin, opts Options) (map[string]File } func pythonModelRequiresOutput(model FileModel) bool { + return pythonModelRequiresOutputForHandler(model, model.Options.PythonHandler) +} + +func pythonModelRequiresOutputForHandler(model FileModel, handler PythonHandler) bool { if len(model.Services) > 0 { return true } - if model.Options.PythonHandler == PythonHandlerProtobuf { + if handler == PythonHandlerProtobuf { return false } if model.PythonTypes == nil { diff --git a/internal/codegen/generator_test.go b/internal/codegen/generator_test.go index 4487990..b4187c1 100644 --- a/internal/codegen/generator_test.go +++ b/internal/codegen/generator_test.go @@ -337,6 +337,112 @@ func TestGenerate_PythonProtobufHandlerSkipsCrossFilePublicTypeModules(t *testin } } +func TestGenerate_PythonMultipleHandlersEmitsDataclassAndProtobufSidecars(t *testing.T) { + plugin := newExampleProtogenPlugin(t) + opts, err := ParseOptions("lang=python,python_runtime=google.protobuf,python_handler=dataclass,protobuf") + if err != nil { + t.Fatalf("ParseOptions: %v", err) + } + + if err := Generate(plugin, opts); err != nil { + t.Fatalf("Generate: %v", err) + } + + dataclassGenerated := string(generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp.py")) + for _, snippet := range []string{ + "@dataclass(slots=True)", + "UNSET = _UnsetType()", + "class ExampleAPIToolHandler(Protocol):", + "def create_report(self, ctx: ToolRequestContext, req: CreateReportRequest) -> CreateReportResponse | Awaitable[CreateReportResponse]:", + "def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: ExampleAPIToolHandler, *, namespace: str | None = None) -> None:", + } { + if !strings.Contains(dataclassGenerated, snippet) { + t.Fatalf("dataclass sidecar missing snippet %q\n%s", snippet, dataclassGenerated) + } + } + if strings.Contains(dataclassGenerated, "req: example_pb2.CreateReportRequest") { + t.Fatalf("dataclass sidecar must not expose protobuf handler signatures\n%s", dataclassGenerated) + } + + protobufGenerated := string(generatedFileContent(t, plugin, "internal/testproto/example/v1/example_mcp_pb.py")) + for _, snippet := range []string{ + "class ExampleAPIToolHandler(Protocol):", + "def create_report(self, ctx: ToolRequestContext, req: example_pb2.CreateReportRequest) -> example_pb2.CreateReportResponse | Awaitable[example_pb2.CreateReportResponse]:", + "def register_example_api_tools(server: mcp.server.lowlevel.Server, impl: ExampleAPIToolHandler, *, namespace: str | None = None) -> None:", + "from_pb=_identity,", + "to_pb=_identity,", + } { + if !strings.Contains(protobufGenerated, snippet) { + t.Fatalf("protobuf sidecar missing snippet %q\n%s", snippet, protobufGenerated) + } + } + for _, snippet := range []string{ + "@dataclass(slots=True)", + "UNSET = _UnsetType()", + "class CreateReportRequest:", + "def _from_pb_create_report_request", + } { + if strings.Contains(protobufGenerated, snippet) { + t.Fatalf("protobuf sidecar must not contain dataclass snippet %q\n%s", snippet, protobufGenerated) + } + } +} + +func TestGenerate_PythonMultipleHandlersKeepsDataclassCrossFileTypesAndSkipsProtobufMessageOnlySidecar(t *testing.T) { + plugin := newTempProtogenPlugin(t, map[string]string{ + "test/v1/shared.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/shared;sharedv1";`, + `message SharedRequest {}`, + `message SharedResponse {}`, + ``, + }, "\n"), + "test/v1/service.proto": strings.Join([]string{ + `syntax = "proto3";`, + `package test.v1;`, + `option go_package = "github.com/easyp-tech/protoc-gen-mcp/internal/codegen/testdata/service;servicev1";`, + `import "test/v1/shared.proto";`, + `service CrossFileAPI {`, + ` rpc UseShared(SharedRequest) returns (SharedResponse);`, + `}`, + ``, + }, "\n"), + }, "test/v1/shared.proto", "test/v1/service.proto") + opts, err := ParseOptions("lang=python,python_handler=dataclass,protobuf") + if err != nil { + t.Fatalf("ParseOptions: %v", err) + } + + if err := Generate(plugin, opts); err != nil { + t.Fatalf("Generate: %v", err) + } + + sharedGenerated := string(generatedFileContent(t, plugin, "test/v1/shared_mcp.py")) + if !strings.Contains(sharedGenerated, "class SharedRequest:") { + t.Fatalf("dataclass shared public type module missing SharedRequest\n%s", sharedGenerated) + } + + serviceDataclassGenerated := string(generatedFileContent(t, plugin, "test/v1/service_mcp.py")) + if !strings.Contains(serviceDataclassGenerated, "from test.v1 import shared_mcp as test_v1_shared_mcp") { + t.Fatalf("dataclass service sidecar must import shared public module\n%s", serviceDataclassGenerated) + } + + serviceProtobufGenerated := string(generatedFileContent(t, plugin, "test/v1/service_mcp_pb.py")) + if !strings.Contains(serviceProtobufGenerated, "from test.v1 import shared_pb2") { + t.Fatalf("protobuf service sidecar must import shared protobuf module\n%s", serviceProtobufGenerated) + } + if strings.Contains(serviceProtobufGenerated, "shared_mcp") { + t.Fatalf("protobuf service sidecar must not import shared public module\n%s", serviceProtobufGenerated) + } + + for _, file := range plugin.Response().GetFile() { + if file.GetName() == "test/v1/shared_mcp_pb.py" { + t.Fatalf("dual handler mode must not emit protobuf sidecar for message-only file:\n%s", file.GetContent()) + } + } +} + func TestGenerate_PythonEmitsPublicTypesForHiddenOnlyServiceImports(t *testing.T) { plugin := newTempProtogenPlugin(t, map[string]string{ "test/v1/shared.proto": strings.Join([]string{ diff --git a/internal/codegen/options.go b/internal/codegen/options.go index a7b767b..bab9d7d 100644 --- a/internal/codegen/options.go +++ b/internal/codegen/options.go @@ -29,15 +29,17 @@ const PythonHandlerDataclass PythonHandler = "dataclass" const PythonHandlerProtobuf PythonHandler = "protobuf" type Options struct { - Language Language - PythonRuntime PythonRuntime - PythonHandler PythonHandler + Language Language + PythonRuntime PythonRuntime + PythonHandler PythonHandler + PythonHandlers []PythonHandler } type OptionsParser struct { - opts Options - sawPythonRuntime bool - sawPythonHandler bool + opts Options + sawPythonRuntime bool + sawPythonHandler bool + allowPythonHandlerContinuation bool } func NewOptionsParser() *OptionsParser { @@ -50,19 +52,28 @@ func NewOptionsParser() *OptionsParser { func (p *OptionsParser) Set(name, value string) error { if isProtogenManagedParam(name) { + p.allowPythonHandlerContinuation = false return nil } switch name { case "lang": p.opts.Language = Language(value) + p.allowPythonHandlerContinuation = false case "python_runtime": p.opts.PythonRuntime = PythonRuntime(value) p.sawPythonRuntime = true + p.allowPythonHandlerContinuation = false case "python_handler": - p.opts.PythonHandler = PythonHandler(value) + p.addPythonHandlerValues(value) p.sawPythonHandler = true + p.allowPythonHandlerContinuation = true default: + if value == "" && p.allowPythonHandlerContinuation { + p.addPythonHandler(PythonHandler(name)) + return nil + } + p.allowPythonHandlerContinuation = false return fmt.Errorf("unknown protoc-gen-mcp option %q", name) } @@ -90,18 +101,24 @@ func (p *OptionsParser) Options() (Options, error) { p.opts.PythonRuntime = PythonRuntimeGoogleProtobuf } if !p.sawPythonHandler { - p.opts.PythonHandler = PythonHandlerDataclass + p.addPythonHandler(PythonHandlerDataclass) } switch p.opts.PythonRuntime { case PythonRuntimeGoogleProtobuf: default: return Options{}, fmt.Errorf("unsupported python_runtime %q", p.opts.PythonRuntime) } - switch p.opts.PythonHandler { - case PythonHandlerDataclass, PythonHandlerProtobuf: - default: - return Options{}, fmt.Errorf("unsupported python_handler %q", p.opts.PythonHandler) + for _, handler := range p.opts.PythonHandlers { + switch handler { + case PythonHandlerDataclass, PythonHandlerProtobuf: + default: + return Options{}, fmt.Errorf("unsupported python_handler %q", handler) + } + } + if len(p.opts.PythonHandlers) == 0 { + return Options{}, fmt.Errorf(`unsupported python_handler ""`) } + p.opts.PythonHandler = p.opts.PythonHandlers[0] default: return Options{}, fmt.Errorf("unsupported lang %q", p.opts.Language) } @@ -109,6 +126,57 @@ func (p *OptionsParser) Options() (Options, error) { return p.opts, nil } +func (p *OptionsParser) addPythonHandlerValues(value string) { + for _, handler := range splitPythonHandlerValue(value) { + p.addPythonHandler(handler) + } +} + +func (p *OptionsParser) addPythonHandler(handler PythonHandler) { + for _, existing := range p.opts.PythonHandlers { + if existing == handler { + return + } + } + p.opts.PythonHandlers = append(p.opts.PythonHandlers, handler) + p.opts.PythonHandler = p.opts.PythonHandlers[0] +} + +func splitPythonHandlerValue(value string) []PythonHandler { + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == '+' || r == '|' || r == ';' + }) + if len(parts) == 0 { + return []PythonHandler{PythonHandler(value)} + } + + handlers := make([]PythonHandler, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + handlers = append(handlers, PythonHandler(part)) + } + if len(handlers) == 0 { + return []PythonHandler{PythonHandler(value)} + } + return handlers +} + +func pythonHandlersForOptions(opts Options) []PythonHandler { + if len(opts.PythonHandlers) > 0 { + return opts.PythonHandlers + } + if opts.PythonHandler != "" { + return []PythonHandler{opts.PythonHandler} + } + if opts.Language == LanguagePython { + return []PythonHandler{PythonHandlerDataclass} + } + return nil +} + // ParseOptions exists for direct parser tests and any non-protogen callers that // need the same option semantics as the plugin entrypoint. func ParseOptions(raw string) (Options, error) { diff --git a/internal/codegen/options_test.go b/internal/codegen/options_test.go index e2f34d7..d3bdc15 100644 --- a/internal/codegen/options_test.go +++ b/internal/codegen/options_test.go @@ -135,6 +135,43 @@ func TestParseOptions_PythonExplicitProtobufHandler(t *testing.T) { } } +func TestParseOptions_PythonMultipleHandlers(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "comma continuation", + raw: "lang=python,python_runtime=google.protobuf,python_handler=dataclass,protobuf", + }, + { + name: "repeated option", + raw: "lang=python,python_handler=dataclass,python_handler=protobuf", + }, + { + name: "plus separated", + raw: "lang=python,python_handler=dataclass+protobuf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts, err := ParseOptions(tt.raw) + if err != nil { + t.Fatalf("ParseOptions returned error: %v", err) + } + + want := []PythonHandler{PythonHandlerDataclass, PythonHandlerProtobuf} + if got := opts.PythonHandlers; !pythonHandlersEqual(got, want) { + t.Fatalf("PythonHandlers = %#v, want %#v", got, want) + } + if opts.PythonHandler != PythonHandlerDataclass { + t.Fatalf("PythonHandler = %q, want primary %q", opts.PythonHandler, PythonHandlerDataclass) + } + }) + } +} + func TestParseOptions_PythonRuntimeConstants(t *testing.T) { if PythonRuntimeGoogleProtobuf != "google.protobuf" { t.Fatalf("PythonRuntimeGoogleProtobuf = %q, want %q", PythonRuntimeGoogleProtobuf, "google.protobuf") @@ -147,6 +184,18 @@ func TestParseOptions_PythonRuntimeConstants(t *testing.T) { } } +func pythonHandlersEqual(got, want []PythonHandler) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + func TestParseOptions_PythonHandlerConstants(t *testing.T) { if PythonHandlerDataclass != "dataclass" { t.Fatalf("PythonHandlerDataclass = %q, want %q", PythonHandlerDataclass, "dataclass") diff --git a/internal/codegen/python_names.go b/internal/codegen/python_names.go index 464c13b..940fcdf 100644 --- a/internal/codegen/python_names.go +++ b/internal/codegen/python_names.go @@ -19,6 +19,17 @@ func pythonOutputPath(file *protogen.File) string { return pythonGeneratedFilenamePrefix(file) + "_mcp.py" } +func pythonProtobufOutputPath(file *protogen.File) string { + return pythonGeneratedFilenamePrefix(file) + "_mcp_pb.py" +} + +func pythonOutputPathForHandler(file *protogen.File, handler PythonHandler, dualHandlers bool) string { + if dualHandlers && handler == PythonHandlerProtobuf { + return pythonProtobufOutputPath(file) + } + return pythonOutputPath(file) +} + func pythonPublicModulePathForProtoPath(protoPath string) string { return strings.ReplaceAll(pythonGeneratedFilenamePrefixForProtoPath(protoPath), "/", ".") + "_mcp" } diff --git a/internal/codegen/render_python.go b/internal/codegen/render_python.go index 1ffa5d5..847f3b2 100644 --- a/internal/codegen/render_python.go +++ b/internal/codegen/render_python.go @@ -14,9 +14,16 @@ func renderPythonFile(plugin *protogen.Plugin, model FileModel) error { if err != nil { return err } - protobufHandlerMode := model.Options.PythonHandler == PythonHandlerProtobuf + return renderPythonFileForHandler(plugin, model, model.Options.PythonHandler, pythonOutputPath(info.file)) +} + +func renderPythonFileForHandler(plugin *protogen.Plugin, model FileModel, handler PythonHandler, filename string) error { + info, err := newPythonRenderInfo(plugin, model) + if err != nil { + return err + } + protobufHandlerMode := handler == PythonHandlerProtobuf - filename := pythonOutputPath(info.file) generated := plugin.NewGeneratedFile(filename, "") generated.P("# Code generated by protoc-gen-mcp. DO NOT EDIT.") From 6d8735097d727326d657db39962b7fa75883195c Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 18:28:34 +0300 Subject: [PATCH 71/74] examples: demonstrate dual Python handler generation --- AGENTS.md | 8 +- README.md | 2 +- .../10_python_protobuf_standalone/README.md | 40 ++- .../10_python_protobuf_standalone/easyp.yaml | 2 +- .../proto/tasks_mcp.py | 282 ++++++++++++++- .../proto/tasks_mcp_pb.py | 329 ++++++++++++++++++ .../10_python_protobuf_standalone/server.py | 12 +- examples/README.md | 13 +- 8 files changed, 638 insertions(+), 50 deletions(-) create mode 100644 examples/10_python_protobuf_standalone/proto/tasks_mcp_pb.py diff --git a/AGENTS.md b/AGENTS.md index 4e2ba4f..7924c85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,8 +61,8 @@ architecture unless explicitly revised. server - `examples/10_python_protobuf_standalone`: Python-only user-style example with its own `pyproject.toml`, `easyp.yaml`, generated `proto`/`mcp` - packages, opt-in `python_handler=protobuf` sidecar, raw `*_pb2` handlers, - and stdio server + packages, opt-in `python_handler=dataclass+protobuf` sidecars, raw + `*_pb2` handlers through `*_mcp_pb.py`, and stdio server - `examples/6_java_standalone`: Java user-style standalone project with its own Gradle build, `easyp.yaml`, protobuf contract, generated Java protobuf classes, generated `lang=java` MCP sidecar, and handwritten stdio server @@ -291,8 +291,8 @@ architecture unless explicitly revised. - `examples/10_python_protobuf_standalone` models an external Python-only raw `*_pb2` handler project with its own `pyproject.toml`, `easyp.yaml`, generated package `__init__.py` files, generated local `mcp.options.*` - modules, opt-in `python_handler=protobuf` sidecar, and checked-in stdio - server + modules, opt-in `python_handler=dataclass+protobuf` sidecars, and + checked-in stdio server that imports the raw `*_mcp_pb.py` sidecar - standalone examples use `examples/easyp.yaml` `deps` plus `examples/easyp.lock` to resolve `mcp/options/v1/options.proto` from `github.com/easyp-tech/protoc-gen-mcp` instead of depending on the local diff --git a/README.md b/README.md index b7d1fad..a947795 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ out the - [3_file_manager](examples/3_file_manager/) - Destructive tools and schema-based string parameter constraints. - [4_crm_system](examples/4_crm_system/) - A full mock system with FieldMask partial updates, custom icons mapping, schemas nested types, and advanced array filters. - [5_python_standalone](examples/5_python_standalone/) - A Python-only user-style project with its own `pyproject.toml`, `easyp.yaml`, generated bindings, and stdio server. -- [10_python_protobuf_standalone](examples/10_python_protobuf_standalone/) - A Python-only user-style project that opts into `python_handler=protobuf` and implements handlers with raw `*_pb2` classes. +- [10_python_protobuf_standalone](examples/10_python_protobuf_standalone/) - A Python-only user-style project that opts into `python_handler=dataclass+protobuf` and implements the raw `*_mcp_pb.py` sidecar with `*_pb2` classes. - [6_java_standalone](examples/6_java_standalone/) - A Java user-style project with its own Gradle build, `easyp.yaml`, protobuf contract, and generated MCP sidecar. - [7_kotlin_standalone](examples/7_kotlin_standalone/) - A Kotlin user-style project with its own Gradle build, `easyp.yaml`, protobuf contract, and generated MCP sidecar. - [8_typescript_standalone](examples/8_typescript_standalone/) - A TypeScript user-style project with its own npm package, `tsconfig.json`, `easyp.yaml`, Protobuf-ES output, generated MCP sidecar, and stdio server. diff --git a/examples/10_python_protobuf_standalone/README.md b/examples/10_python_protobuf_standalone/README.md index 7c69ac0..4429fab 100644 --- a/examples/10_python_protobuf_standalone/README.md +++ b/examples/10_python_protobuf_standalone/README.md @@ -1,13 +1,15 @@ # Standalone Python MCP Server With Raw Protobuf Handlers This example is structured like a user-owned Python project and opts into -`python_handler: protobuf`. Generated handlers receive and return raw -`*_pb2` message classes, represented here by `tasks_pb2.*`, instead of -generated dataclasses. +`python_handler: dataclass+protobuf`. Generation emits both public surfaces: +`proto/tasks_mcp.py` for dataclass handlers and `proto/tasks_mcp_pb.py` for +raw `*_pb2` handlers. The handwritten server uses the raw sidecar with +`tasks_pb2.*` message classes. Use this layout when you already have Python server code written against -standard `google.protobuf` generated classes. If you want the default -dataclass API with `UNSET` and explicit `oneof` wrappers, use +standard `google.protobuf` generated classes and also want dataclass bindings +available. If you only want the default dataclass API with `UNSET` and +explicit `oneof` wrappers, use [`../5_python_standalone`](../5_python_standalone/) instead. ## Generate @@ -21,45 +23,45 @@ The key generator option is: ```yaml lang: python python_runtime: google.protobuf -python_handler: protobuf +python_handler: dataclass+protobuf ``` -`python_handler: protobuf` is protobuf-only and writes the raw handler sidecar -to `proto/tasks_mcp.py`. If a project wants both public surfaces in one -generation pass, use `python_handler: dataclass+protobuf`; then -`proto/tasks_mcp.py` remains the dataclass sidecar and `proto/tasks_mcp_pb.py` -contains the raw `tasks_pb2.*` handler sidecar. +`python_handler: protobuf` remains available for protobuf-only projects and +writes the raw handler sidecar to `proto/tasks_mcp.py`. This example uses +`python_handler: dataclass+protobuf` to demonstrate simultaneous generation. That generates: - `proto/tasks_pb2.py` - `proto/tasks_mcp.py` +- `proto/tasks_mcp_pb.py` - `proto/__init__.py` - `mcp/__init__.py` - `mcp/options/v1/options_pb2.py` -The generated MCP sidecar still exposes the normal registration helper: +The raw generated MCP sidecar still exposes the normal registration helper: ```python -from proto import tasks_mcp, tasks_pb2 +from proto import tasks_mcp_pb, tasks_pb2 -class TaskStore(tasks_mcp.TaskAPIToolHandler): +class TaskStore(tasks_mcp_pb.TaskAPIToolHandler): def create_task( self, - _ctx: tasks_mcp.ToolRequestContext, + _ctx: tasks_mcp_pb.ToolRequestContext, req: tasks_pb2.CreateTaskRequest, ) -> tasks_pb2.CreateTaskResponse: ... -tasks_mcp.register_task_api_tools(server, TaskStore()) +tasks_mcp_pb.register_task_api_tools(server, TaskStore()) ``` -In protobuf mode, generated dataclasses, `UNSET`, and dataclass mapper helpers -are omitted. Runtime validation, ProtoJSON parsing, output validation, +In the protobuf sidecar, generated dataclasses, `UNSET`, and dataclass mapper +helpers are omitted. Runtime validation, ProtoJSON parsing, output validation, structured content, tool names, annotations, icons, and execution metadata are -still owned by the generated MCP sidecar. +still owned by the generated MCP sidecar. The dataclass sidecar remains +available in `proto/tasks_mcp.py` for users who prefer wrapper types. ## Run diff --git a/examples/10_python_protobuf_standalone/easyp.yaml b/examples/10_python_protobuf_standalone/easyp.yaml index be722b8..e692cc1 100644 --- a/examples/10_python_protobuf_standalone/easyp.yaml +++ b/examples/10_python_protobuf_standalone/easyp.yaml @@ -35,4 +35,4 @@ generate: paths: source_relative lang: python python_runtime: google.protobuf - python_handler: protobuf + python_handler: dataclass+protobuf diff --git a/examples/10_python_protobuf_standalone/proto/tasks_mcp.py b/examples/10_python_protobuf_standalone/proto/tasks_mcp.py index c4179b1..b839fec 100644 --- a/examples/10_python_protobuf_standalone/proto/tasks_mcp.py +++ b/examples/10_python_protobuf_standalone/proto/tasks_mcp.py @@ -22,6 +22,72 @@ ToolRequestContext = mcp.server.session.ServerSession +class _UnsetType: + __slots__ = () + + def __repr__(self) -> str: + return "UNSET" + +UNSET = _UnsetType() + +JSONValue: TypeAlias = dict[str, Any] | list[Any] | str | int | float | bool | None +ProtoAny: TypeAlias = dict[str, Any] +Timestamp: TypeAlias = str +Duration: TypeAlias = str +FieldMask: TypeAlias = str +Struct: TypeAlias = dict[str, Any] +Value: TypeAlias = JSONValue +ListValue: TypeAlias = list[Any] +BoolValue: TypeAlias = bool +StringValue: TypeAlias = str +BytesValue: TypeAlias = bytes +Int32Value: TypeAlias = int +UInt32Value: TypeAlias = int +Int64Value: TypeAlias = int +UInt64Value: TypeAlias = int +FloatValue: TypeAlias = float +DoubleValue: TypeAlias = float + +@dataclass(slots=True) +class Empty: + pass + +@dataclass(slots=True) +class CreateTaskRequest: + title: str + tags: list[str] = field(default_factory=list) + +@dataclass(slots=True) +class Task: + id: str + title: str + completed: bool + created_at: Timestamp + tags: list[str] = field(default_factory=list) + +@dataclass(slots=True) +class CreateTaskResponse: + task: Task + +@dataclass(slots=True) +class ListTasksRequest: + include_completed: bool | _UnsetType = UNSET + tags: list[str] = field(default_factory=list) + limit: int | _UnsetType = UNSET + +@dataclass(slots=True) +class ListTasksResponse: + tasks: list[Task] = field(default_factory=list) + +@dataclass(slots=True) +class HealthRequest: + pass + +@dataclass(slots=True) +class HealthResponse: + ok: bool + task_count: int + @dataclass(frozen=True) class _RegisteredTool: name: str @@ -68,9 +134,6 @@ def _protojson_to_message(arguments: dict[str, Any], message: Any) -> Any: json_format.ParseDict(arguments, message) return message -def _identity(value: Any) -> Any: - return value - def _normalize_tool_segment(segment: str | None) -> str: if segment is None: return "" @@ -257,12 +320,207 @@ async def _dispatch_call(registry: _ServerToolRegistry, name: str, arguments: di raise RuntimeError(f"mcpruntime: validate output for tool {name!r}: {error}") from error return mcp.types.CallToolResult(content=content, structuredContent=payload) +def _enum_from_pb(enum_type: type[enum.IntEnum], value: int) -> enum.IntEnum: + return enum_type(value) + +def _json_to_message(value: Any, message: Any) -> Any: + json_format.Parse(json.dumps(value), message) + return message + +def _from_pb_any(message: any_pb2.Any) -> ProtoAny: + return json.loads(_message_to_json(message)) + +def _to_pb_any(value: ProtoAny) -> any_pb2.Any: + return _json_to_message(value, any_pb2.Any()) + +def _from_pb_timestamp(message: timestamp_pb2.Timestamp) -> Timestamp: + return json.loads(_message_to_json(message)) + +def _to_pb_timestamp(value: Timestamp) -> timestamp_pb2.Timestamp: + return _json_to_message(value, timestamp_pb2.Timestamp()) + +def _from_pb_duration(message: duration_pb2.Duration) -> Duration: + return json.loads(_message_to_json(message)) + +def _to_pb_duration(value: Duration) -> duration_pb2.Duration: + return _json_to_message(value, duration_pb2.Duration()) + +def _from_pb_field_mask(message: field_mask_pb2.FieldMask) -> FieldMask: + return json.loads(_message_to_json(message)) + +def _to_pb_field_mask(value: FieldMask) -> field_mask_pb2.FieldMask: + return _json_to_message(value, field_mask_pb2.FieldMask()) + +def _from_pb_struct(message: struct_pb2.Struct) -> Struct: + return json.loads(_message_to_json(message)) + +def _to_pb_struct(value: Struct) -> struct_pb2.Struct: + return _json_to_message(value, struct_pb2.Struct()) + +def _from_pb_list_value(message: struct_pb2.ListValue) -> ListValue: + return json.loads(_message_to_json(message)) + +def _to_pb_list_value(value: ListValue) -> struct_pb2.ListValue: + return _json_to_message(value, struct_pb2.ListValue()) + +def _from_pb_value(message: struct_pb2.Value) -> Value: + return json.loads(_message_to_json(message)) + +def _to_pb_value(value: Value) -> struct_pb2.Value: + return _json_to_message(value, struct_pb2.Value()) + +def _from_pb_empty(message: empty_pb2.Empty) -> Empty: + return Empty() + +def _to_pb_empty(value: Empty) -> empty_pb2.Empty: + return empty_pb2.Empty() + +def _from_pb_bool_value(message: wrappers_pb2.BoolValue) -> BoolValue: + return message.value + +def _to_pb_bool_value(value: BoolValue) -> wrappers_pb2.BoolValue: + return wrappers_pb2.BoolValue(value=value) + +def _from_pb_string_value(message: wrappers_pb2.StringValue) -> StringValue: + return message.value + +def _to_pb_string_value(value: StringValue) -> wrappers_pb2.StringValue: + return wrappers_pb2.StringValue(value=value) + +def _from_pb_bytes_value(message: wrappers_pb2.BytesValue) -> BytesValue: + return message.value + +def _to_pb_bytes_value(value: BytesValue) -> wrappers_pb2.BytesValue: + return wrappers_pb2.BytesValue(value=value) + +def _from_pb_int32_value(message: wrappers_pb2.Int32Value) -> Int32Value: + return message.value + +def _to_pb_int32_value(value: Int32Value) -> wrappers_pb2.Int32Value: + return wrappers_pb2.Int32Value(value=value) + +def _from_pb_uint32_value(message: wrappers_pb2.UInt32Value) -> UInt32Value: + return message.value + +def _to_pb_uint32_value(value: UInt32Value) -> wrappers_pb2.UInt32Value: + return wrappers_pb2.UInt32Value(value=value) + +def _from_pb_int64_value(message: wrappers_pb2.Int64Value) -> Int64Value: + return message.value + +def _to_pb_int64_value(value: Int64Value) -> wrappers_pb2.Int64Value: + return wrappers_pb2.Int64Value(value=value) + +def _from_pb_uint64_value(message: wrappers_pb2.UInt64Value) -> UInt64Value: + return message.value + +def _to_pb_uint64_value(value: UInt64Value) -> wrappers_pb2.UInt64Value: + return wrappers_pb2.UInt64Value(value=value) + +def _from_pb_float_value(message: wrappers_pb2.FloatValue) -> FloatValue: + return message.value + +def _to_pb_float_value(value: FloatValue) -> wrappers_pb2.FloatValue: + return wrappers_pb2.FloatValue(value=value) + +def _from_pb_double_value(message: wrappers_pb2.DoubleValue) -> DoubleValue: + return message.value + +def _to_pb_double_value(value: DoubleValue) -> wrappers_pb2.DoubleValue: + return wrappers_pb2.DoubleValue(value=value) + +def _from_pb_create_task_request(message: tasks_pb2.CreateTaskRequest) -> CreateTaskRequest: + return CreateTaskRequest( + title=message.title, + tags=list(message.tags), + ) + +def _to_pb_create_task_request(value: CreateTaskRequest) -> tasks_pb2.CreateTaskRequest: + message = tasks_pb2.CreateTaskRequest() + message.title = value.title + message.tags.extend(value.tags) + return message + +def _from_pb_task(message: tasks_pb2.Task) -> Task: + return Task( + id=message.id, + title=message.title, + completed=message.completed, + tags=list(message.tags), + created_at=_from_pb_timestamp(message.created_at), + ) + +def _to_pb_task(value: Task) -> tasks_pb2.Task: + message = tasks_pb2.Task() + message.id = value.id + message.title = value.title + message.completed = value.completed + message.tags.extend(value.tags) + message.created_at.CopyFrom(_to_pb_timestamp(value.created_at)) + return message + +def _from_pb_create_task_response(message: tasks_pb2.CreateTaskResponse) -> CreateTaskResponse: + return CreateTaskResponse( + task=_from_pb_task(message.task), + ) + +def _to_pb_create_task_response(value: CreateTaskResponse) -> tasks_pb2.CreateTaskResponse: + message = tasks_pb2.CreateTaskResponse() + message.task.CopyFrom(_to_pb_task(value.task)) + return message + +def _from_pb_list_tasks_request(message: tasks_pb2.ListTasksRequest) -> ListTasksRequest: + return ListTasksRequest( + include_completed=message.include_completed if message.HasField("include_completed") else UNSET, + tags=list(message.tags), + limit=message.limit if message.HasField("limit") else UNSET, + ) + +def _to_pb_list_tasks_request(value: ListTasksRequest) -> tasks_pb2.ListTasksRequest: + message = tasks_pb2.ListTasksRequest() + if value.include_completed is not UNSET: + message.include_completed = value.include_completed + message.tags.extend(value.tags) + if value.limit is not UNSET: + message.limit = value.limit + return message + +def _from_pb_list_tasks_response(message: tasks_pb2.ListTasksResponse) -> ListTasksResponse: + return ListTasksResponse( + tasks=[_from_pb_task(item) for item in message.tasks], + ) + +def _to_pb_list_tasks_response(value: ListTasksResponse) -> tasks_pb2.ListTasksResponse: + message = tasks_pb2.ListTasksResponse() + message.tasks.extend(_to_pb_task(item) for item in value.tasks) + return message + +def _from_pb_health_request(message: tasks_pb2.HealthRequest) -> HealthRequest: + return HealthRequest( + ) + +def _to_pb_health_request(value: HealthRequest) -> tasks_pb2.HealthRequest: + message = tasks_pb2.HealthRequest() + return message + +def _from_pb_health_response(message: tasks_pb2.HealthResponse) -> HealthResponse: + return HealthResponse( + ok=message.ok, + task_count=message.task_count, + ) + +def _to_pb_health_response(value: HealthResponse) -> tasks_pb2.HealthResponse: + message = tasks_pb2.HealthResponse() + message.ok = value.ok + message.task_count = value.task_count + return message + class TaskAPIToolHandler(Protocol): - def create_task(self, ctx: ToolRequestContext, req: tasks_pb2.CreateTaskRequest) -> tasks_pb2.CreateTaskResponse | Awaitable[tasks_pb2.CreateTaskResponse]: + def create_task(self, ctx: ToolRequestContext, req: CreateTaskRequest) -> CreateTaskResponse | Awaitable[CreateTaskResponse]: ... - def list_tasks(self, ctx: ToolRequestContext, req: tasks_pb2.ListTasksRequest) -> tasks_pb2.ListTasksResponse | Awaitable[tasks_pb2.ListTasksResponse]: + def list_tasks(self, ctx: ToolRequestContext, req: ListTasksRequest) -> ListTasksResponse | Awaitable[ListTasksResponse]: ... - def health(self, ctx: ToolRequestContext, req: tasks_pb2.HealthRequest) -> tasks_pb2.HealthResponse | Awaitable[tasks_pb2.HealthResponse]: + def health(self, ctx: ToolRequestContext, req: HealthRequest) -> HealthResponse | Awaitable[HealthResponse]: ... def register_task_api_tools(server: mcp.server.lowlevel.Server, impl: TaskAPIToolHandler, *, namespace: str | None = None) -> None: @@ -278,8 +536,8 @@ def register_task_api_tools(server: mcp.server.lowlevel.Server, impl: TaskAPIToo output_schema_json=TASK_API_CREATE_TASK_OUTPUT_SCHEMA_JSON, request_type=tasks_pb2.CreateTaskRequest, response_type=tasks_pb2.CreateTaskResponse, - from_pb=_identity, - to_pb=_identity, + from_pb=_from_pb_create_task_request, + to_pb=_to_pb_create_task_response, handler=impl.create_task, annotations={"destructiveHint": False, "openWorldHint": False}, icons=None, @@ -293,8 +551,8 @@ def register_task_api_tools(server: mcp.server.lowlevel.Server, impl: TaskAPIToo output_schema_json=TASK_API_LIST_TASKS_OUTPUT_SCHEMA_JSON, request_type=tasks_pb2.ListTasksRequest, response_type=tasks_pb2.ListTasksResponse, - from_pb=_identity, - to_pb=_identity, + from_pb=_from_pb_list_tasks_request, + to_pb=_to_pb_list_tasks_response, handler=impl.list_tasks, annotations={"readOnlyHint": True, "openWorldHint": False}, icons=None, @@ -308,8 +566,8 @@ def register_task_api_tools(server: mcp.server.lowlevel.Server, impl: TaskAPIToo output_schema_json=TASK_API_HEALTH_OUTPUT_SCHEMA_JSON, request_type=tasks_pb2.HealthRequest, response_type=tasks_pb2.HealthResponse, - from_pb=_identity, - to_pb=_identity, + from_pb=_from_pb_health_request, + to_pb=_to_pb_health_response, handler=impl.health, annotations={"readOnlyHint": True, "openWorldHint": False}, icons=None, diff --git a/examples/10_python_protobuf_standalone/proto/tasks_mcp_pb.py b/examples/10_python_protobuf_standalone/proto/tasks_mcp_pb.py new file mode 100644 index 0000000..c4179b1 --- /dev/null +++ b/examples/10_python_protobuf_standalone/proto/tasks_mcp_pb.py @@ -0,0 +1,329 @@ +# Code generated by protoc-gen-mcp. DO NOT EDIT. +# source: proto/tasks.proto +from __future__ import annotations + +import enum +import inspect +import json +import weakref +from dataclasses import dataclass, field +from typing import Any, Awaitable, Protocol, TypeAlias + +import jsonschema +import mcp.server.lowlevel +import mcp.server.session +import mcp.shared.exceptions +import mcp.types +from google.protobuf import any_pb2, duration_pb2, empty_pb2, field_mask_pb2, json_format, struct_pb2, timestamp_pb2, wrappers_pb2 +try: + from . import tasks_pb2 +except ImportError: + import tasks_pb2 + +ToolRequestContext = mcp.server.session.ServerSession + +@dataclass(frozen=True) +class _RegisteredTool: + name: str + title: str + description: str + input_schema_json: str + output_schema_json: str + request_type: type[Any] + response_type: type[Any] + from_pb: Any + to_pb: Any + handler: Any + annotations: dict[str, Any] | None + icons: list[dict[str, Any]] | None + execution: dict[str, Any] | None = None + +class _ServerToolRegistry: + def __init__(self, server: mcp.server.lowlevel.Server) -> None: + self.server = server + self.tools: dict[str, _RegisteredTool] = {} + self.reserved_tool_names = _get_reserved_tool_names(server) + self.handlers_installed = False + self.previous_list_tools: Any | None = None + self.previous_call_tool: Any | None = None + + def add_tool(self, tool: _RegisteredTool) -> None: + if tool.name in self.reserved_tool_names: + raise ValueError(f"duplicate tool registration: {tool.name}") + self.tools[tool.name] = tool + self.reserved_tool_names.add(tool.name) + + def list_tools(self) -> list[mcp.types.Tool]: + return [_build_tool(tool) for tool in self.tools.values()] + + async def call_tool(self, name: str, arguments: dict[str, Any] | None, context: ToolRequestContext | None = None) -> mcp.types.CallToolResult: + return await _dispatch_call(self, name, arguments or {}, context) + +_SERVER_REGISTRIES: weakref.WeakKeyDictionary[mcp.server.lowlevel.Server, _ServerToolRegistry] = weakref.WeakKeyDictionary() + +def _load_schema(schema_json: str) -> dict[str, Any]: + return json.loads(schema_json) + +def _protojson_to_message(arguments: dict[str, Any], message: Any) -> Any: + json_format.ParseDict(arguments, message) + return message + +def _identity(value: Any) -> Any: + return value + +def _normalize_tool_segment(segment: str | None) -> str: + if segment is None: + return "" + parts = [part for part in segment.strip().replace(".", "_").split("_") if part] + return "_".join(parts) + +def _normalize_namespace(namespace: str | None, default: str) -> str: + if namespace is None: + namespace = default + return _normalize_tool_segment(namespace) + +def _tool_name(namespace: str, method_name: str) -> str: + namespace = _normalize_tool_segment(namespace) + method_name = _normalize_tool_segment(method_name) + if namespace == '': + return method_name + if method_name == '': + return namespace + return f"{namespace}_{method_name}" + +def _get_registry(server: mcp.server.lowlevel.Server) -> _ServerToolRegistry: + registry = _SERVER_REGISTRIES.get(server) + if registry is None: + registry = _ServerToolRegistry(server) + _SERVER_REGISTRIES[server] = registry + return registry + +_RESERVED_TOOL_NAMES_ATTR = "_protoc_gen_mcp_reserved_tool_names" + +def _get_reserved_tool_names(server: mcp.server.lowlevel.Server) -> set[str]: + reserved = getattr(server, _RESERVED_TOOL_NAMES_ATTR, None) + if reserved is None: + reserved = set() + setattr(server, _RESERVED_TOOL_NAMES_ATTR, reserved) + return reserved + +def _build_list_tools_request(request: Any) -> mcp.types.ListToolsRequest: + if isinstance(request, mcp.types.ListToolsRequest): + return request + return mcp.types.ListToolsRequest() + +def _merge_tools(previous: list[mcp.types.Tool], current: list[mcp.types.Tool]) -> list[mcp.types.Tool]: + merged: list[mcp.types.Tool] = [] + names: set[str] = set() + for tool in previous: + if tool.name in names: + raise ValueError(f"duplicate tool registration: {tool.name}") + names.add(tool.name) + merged.append(tool) + for tool in current: + if tool.name in names: + raise ValueError(f"duplicate tool registration: {tool.name}") + names.add(tool.name) + merged.append(tool) + return merged + +def _install_server_handlers(server: mcp.server.lowlevel.Server) -> _ServerToolRegistry: + registry = _get_registry(server) + if registry.handlers_installed: + return registry + registry.previous_list_tools = server.request_handlers.get(mcp.types.ListToolsRequest) + registry.previous_call_tool = server.request_handlers.get(mcp.types.CallToolRequest) + + async def _list_tools(request: Any) -> mcp.types.ServerResult: + current = _SERVER_REGISTRIES.get(server) + list_request = _build_list_tools_request(request) + previous_tools: list[mcp.types.Tool] = [] + meta: dict[str, Any] | None = None + if current is not None: + if current.previous_list_tools is not None: + previous_result = await current.previous_list_tools(list_request) + if not isinstance(previous_result.root, mcp.types.ListToolsResult): + return previous_result + if getattr(list_request.params, "cursor", None) is not None: + raise ValueError("cannot compose protoc-gen-mcp tools with paginated tools/list handlers") + if previous_result.root.nextCursor is not None: + raise ValueError("cannot compose protoc-gen-mcp tools with paginated tools/list handlers") + meta = getattr(previous_result.root, "meta", None) + previous_tools = list(previous_result.root.tools) + current_tools = [] if current is None else current.list_tools() + tools = _merge_tools(previous_tools, current_tools) + server._tool_cache.clear() + for tool in tools: + server._tool_cache[tool.name] = tool + return mcp.types.ServerResult(mcp.types.ListToolsResult(tools=tools, _meta=meta)) + + async def _call_tool(request: mcp.types.CallToolRequest) -> mcp.types.ServerResult: + current = _SERVER_REGISTRIES.get(server) + if current is None: + return mcp.types.ServerResult(_tool_error_result("tool registry is not installed")) + if request.params.name not in current.tools: + if current.previous_call_tool is not None: + return await current.previous_call_tool(request) + _invalid_params_error(request.params.name, "unknown tool") + if current.previous_call_tool is not None: + await server.request_handlers[mcp.types.ListToolsRequest](mcp.types.ListToolsRequest()) + result = await current.call_tool(request.params.name, request.params.arguments or {}, server.request_context.session) + return mcp.types.ServerResult(result) + + server.request_handlers[mcp.types.ListToolsRequest] = _list_tools + server.request_handlers[mcp.types.CallToolRequest] = _call_tool + + registry.handlers_installed = True + return registry + +def _tool_annotations(raw: dict[str, Any] | None) -> mcp.types.ToolAnnotations | None: + if raw is None: + return None + return mcp.types.ToolAnnotations(**raw) + +def _tool_execution(raw: dict[str, Any] | None) -> mcp.types.ToolExecution | None: + if raw is None: + return None + return mcp.types.ToolExecution(**raw) + +def _tool_error_result(message: str) -> mcp.types.CallToolResult: + return mcp.types.CallToolResult( + content=[mcp.types.TextContent(type="text", text=message)], + isError=True, + ) + +def _invalid_params_error(tool_name: str, error: Any) -> mcp.shared.exceptions.McpError: + raise mcp.shared.exceptions.McpError( + mcp.types.ErrorData( + code=mcp.types.INVALID_PARAMS, + message=f"invalid arguments for tool {tool_name!r}: {error}", + ) + ) + +def _build_tool(tool: _RegisteredTool) -> Any: + return mcp.types.Tool( + name=tool.name, + title=tool.title, + description=tool.description, + inputSchema=_load_schema(tool.input_schema_json), + outputSchema=_load_schema(tool.output_schema_json), + annotations=_tool_annotations(tool.annotations), + icons=tool.icons, + execution=_tool_execution(tool.execution), + ) + +async def _maybe_await(result: Any) -> Any: + if inspect.isawaitable(result): + return await result + return result + +def _message_to_json(message: Any) -> str: + kwargs = {"preserving_proto_field_name": False} + try: + return json_format.MessageToJson(message, always_print_fields_with_no_presence=True, **kwargs) + except TypeError: + return json_format.MessageToJson(message, including_default_value_fields=True, **kwargs) + +async def _dispatch_call(registry: _ServerToolRegistry, name: str, arguments: dict[str, Any], context: ToolRequestContext | None) -> mcp.types.CallToolResult: + tool = registry.tools.get(name) + if tool is None: + _invalid_params_error(name, "unknown tool") + try: + jsonschema.validate(arguments, _load_schema(tool.input_schema_json)) + except jsonschema.ValidationError as error: + _invalid_params_error(name, error) + try: + request_pb = _protojson_to_message(arguments, tool.request_type()) + request_dc = tool.from_pb(request_pb) + except Exception as error: + _invalid_params_error(name, error) + try: + response_dc = await _maybe_await(tool.handler(context, request_dc)) + except Exception as error: + return _tool_error_result(str(error)) + try: + if response_dc is None: + response_pb = tool.response_type() + else: + response_pb = tool.to_pb(response_dc) + payload = json.loads(_message_to_json(response_pb)) + except Exception as error: + raise RuntimeError(f"mcpruntime: marshal output for tool {name!r}: {error}") from error + text = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + content = [mcp.types.TextContent(type="text", text=text)] + try: + jsonschema.validate(payload, _load_schema(tool.output_schema_json)) + except jsonschema.ValidationError as error: + raise RuntimeError(f"mcpruntime: validate output for tool {name!r}: {error}") from error + return mcp.types.CallToolResult(content=content, structuredContent=payload) + +class TaskAPIToolHandler(Protocol): + def create_task(self, ctx: ToolRequestContext, req: tasks_pb2.CreateTaskRequest) -> tasks_pb2.CreateTaskResponse | Awaitable[tasks_pb2.CreateTaskResponse]: + ... + def list_tasks(self, ctx: ToolRequestContext, req: tasks_pb2.ListTasksRequest) -> tasks_pb2.ListTasksResponse | Awaitable[tasks_pb2.ListTasksResponse]: + ... + def health(self, ctx: ToolRequestContext, req: tasks_pb2.HealthRequest) -> tasks_pb2.HealthResponse | Awaitable[tasks_pb2.HealthResponse]: + ... + +def register_task_api_tools(server: mcp.server.lowlevel.Server, impl: TaskAPIToolHandler, *, namespace: str | None = None) -> None: + if impl is None: + raise ValueError("register_task_api_tools: impl is nil") + registry = _install_server_handlers(server) + resolved_namespace = _normalize_namespace(namespace, "tasks") + registry.add_tool(_RegisteredTool( + name=_tool_name(resolved_namespace, "CreateTask"), + title="Create task", + description="Create a task in the local in-memory task list.", + input_schema_json=TASK_API_CREATE_TASK_INPUT_SCHEMA_JSON, + output_schema_json=TASK_API_CREATE_TASK_OUTPUT_SCHEMA_JSON, + request_type=tasks_pb2.CreateTaskRequest, + response_type=tasks_pb2.CreateTaskResponse, + from_pb=_identity, + to_pb=_identity, + handler=impl.create_task, + annotations={"destructiveHint": False, "openWorldHint": False}, + icons=None, + execution=None, + )) + registry.add_tool(_RegisteredTool( + name=_tool_name(resolved_namespace, "ListTasks"), + title="List tasks", + description="List tasks by completion state and tags.", + input_schema_json=TASK_API_LIST_TASKS_INPUT_SCHEMA_JSON, + output_schema_json=TASK_API_LIST_TASKS_OUTPUT_SCHEMA_JSON, + request_type=tasks_pb2.ListTasksRequest, + response_type=tasks_pb2.ListTasksResponse, + from_pb=_identity, + to_pb=_identity, + handler=impl.list_tasks, + annotations={"readOnlyHint": True, "openWorldHint": False}, + icons=None, + execution=None, + )) + registry.add_tool(_RegisteredTool( + name=_tool_name(resolved_namespace, "Health"), + title="Task server health", + description="Verify that the raw protobuf task MCP server is alive.", + input_schema_json=TASK_API_HEALTH_INPUT_SCHEMA_JSON, + output_schema_json=TASK_API_HEALTH_OUTPUT_SCHEMA_JSON, + request_type=tasks_pb2.HealthRequest, + response_type=tasks_pb2.HealthResponse, + from_pb=_identity, + to_pb=_identity, + handler=impl.health, + annotations={"readOnlyHint": True, "openWorldHint": False}, + icons=None, + execution=None, + )) + +TASK_API_CREATE_TASK_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"python\",\"protobuf\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable task title.\",\"examples\":[\"Ship protobuf handler docs\"],\"minLength\":1,\"maxLength\":120}},\"examples\":[{\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"title\"],\"additionalProperties\":false}" + +TASK_API_CREATE_TASK_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"task\":{\"type\":\"object\",\"properties\":{\"completed\":{\"type\":\"boolean\",\"description\":\"Whether the task has been completed.\",\"examples\":[true]},\"createdAt\":{\"type\":\"string\",\"description\":\"Server-assigned creation timestamp.\",\"readOnly\":true,\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"id\":{\"type\":\"string\",\"description\":\"Server-assigned task identifier.\",\"readOnly\":true,\"examples\":[\"example\"]},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"python\",\"protobuf\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable task title.\",\"examples\":[\"Ship protobuf handler docs\"],\"minLength\":1,\"maxLength\":120}},\"examples\":[{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"id\",\"title\",\"completed\",\"createdAt\"],\"additionalProperties\":false}},\"examples\":[{\"task\":{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}}],\"required\":[\"task\"],\"additionalProperties\":false}" + +TASK_API_LIST_TASKS_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"includeCompleted\":{\"type\":[\"boolean\",\"null\"],\"description\":\"When false, completed tasks are excluded.\",\"default\":true,\"examples\":[true]},\"limit\":{\"type\":[\"integer\",\"null\"],\"description\":\"Maximum number of tasks to return.\",\"examples\":[-1],\"minimum\":1,\"maximum\":50},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags; a task must have every requested tag.\",\"examples\":[\"example\"]},\"description\":\"Optional tags; a task must have every requested tag.\",\"examples\":[[\"protobuf\"]],\"uniqueItems\":true}},\"examples\":[{\"includeCompleted\":true,\"limit\":-1,\"tags\":[\"example\"]}],\"additionalProperties\":false}" + +TASK_API_LIST_TASKS_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"tasks\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"object\",\"properties\":{\"completed\":{\"type\":\"boolean\",\"description\":\"Whether the task has been completed.\",\"examples\":[true]},\"createdAt\":{\"type\":\"string\",\"description\":\"Server-assigned creation timestamp.\",\"readOnly\":true,\"examples\":[\"2026-03-09T10:11:12Z\"],\"format\":\"date-time\"},\"id\":{\"type\":\"string\",\"description\":\"Server-assigned task identifier.\",\"readOnly\":true,\"examples\":[\"example\"]},\"tags\":{\"type\":[\"array\",\"null\"],\"items\":{\"type\":\"string\",\"description\":\"Optional tags used for filtering.\",\"examples\":[\"example\"]},\"description\":\"Optional tags used for filtering.\",\"examples\":[[\"python\",\"protobuf\"]],\"uniqueItems\":true},\"title\":{\"type\":\"string\",\"description\":\"Short human-readable task title.\",\"examples\":[\"Ship protobuf handler docs\"],\"minLength\":1,\"maxLength\":120}},\"examples\":[{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}],\"required\":[\"id\",\"title\",\"completed\",\"createdAt\"],\"additionalProperties\":false},\"examples\":[[{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}]]}},\"examples\":[{\"tasks\":[{\"completed\":true,\"createdAt\":\"2026-03-09T10:11:12Z\",\"id\":\"example\",\"tags\":[\"example\"],\"title\":\"example\"}]}],\"additionalProperties\":false}" + +TASK_API_HEALTH_INPUT_SCHEMA_JSON = "{\"type\":\"object\",\"additionalProperties\":false}" + +TASK_API_HEALTH_OUTPUT_SCHEMA_JSON = "{\"type\":\"object\",\"properties\":{\"ok\":{\"type\":\"boolean\",\"examples\":[true]},\"taskCount\":{\"type\":\"integer\",\"description\":\"Current number of tasks in memory.\",\"readOnly\":true,\"examples\":[-1]}},\"examples\":[{\"ok\":true,\"taskCount\":-1}],\"required\":[\"ok\",\"taskCount\"],\"additionalProperties\":false}" diff --git a/examples/10_python_protobuf_standalone/server.py b/examples/10_python_protobuf_standalone/server.py index c905881..a309fe3 100644 --- a/examples/10_python_protobuf_standalone/server.py +++ b/examples/10_python_protobuf_standalone/server.py @@ -7,7 +7,7 @@ import mcp.server.stdio from google.protobuf import timestamp_pb2 -from proto import tasks_mcp, tasks_pb2 +from proto import tasks_mcp_pb, tasks_pb2 def _now_timestamp() -> timestamp_pb2.Timestamp: @@ -16,14 +16,14 @@ def _now_timestamp() -> timestamp_pb2.Timestamp: return timestamp -class TaskStore(tasks_mcp.TaskAPIToolHandler): +class TaskStore(tasks_mcp_pb.TaskAPIToolHandler): def __init__(self) -> None: self._next_id = 1 self._tasks: dict[str, tasks_pb2.Task] = {} def create_task( self, - _ctx: tasks_mcp.ToolRequestContext, + _ctx: tasks_mcp_pb.ToolRequestContext, req: tasks_pb2.CreateTaskRequest, ) -> tasks_pb2.CreateTaskResponse: task_id = f"task-{self._next_id}" @@ -41,7 +41,7 @@ def create_task( def list_tasks( self, - _ctx: tasks_mcp.ToolRequestContext, + _ctx: tasks_mcp_pb.ToolRequestContext, req: tasks_pb2.ListTasksRequest, ) -> tasks_pb2.ListTasksResponse: include_completed = req.include_completed if req.HasField("include_completed") else True @@ -62,7 +62,7 @@ def list_tasks( def health( self, - _ctx: tasks_mcp.ToolRequestContext, + _ctx: tasks_mcp_pb.ToolRequestContext, _req: tasks_pb2.HealthRequest, ) -> tasks_pb2.HealthResponse: return tasks_pb2.HealthResponse(ok=True, task_count=len(self._tasks)) @@ -70,7 +70,7 @@ def health( def new_server() -> mcp.server.lowlevel.Server: server = mcp.server.lowlevel.Server("standalone-python-protobuf-tasks", version="0.1.0") - tasks_mcp.register_task_api_tools(server, TaskStore()) + tasks_mcp_pb.register_task_api_tools(server, TaskStore()) return server diff --git a/examples/README.md b/examples/README.md index 9bb27dc..d96ebc2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -120,16 +120,15 @@ protobuf contract, generated bindings, and stdio server. keep `*_pb2` request/response classes. ### [10_python_protobuf_standalone](./10_python_protobuf_standalone) (Python Raw Protobuf Handler User Project) -A pure Python MCP server example that opts into `python_handler: protobuf`. -- **Raw `*_pb2` handlers**: the server imports `from proto import tasks_mcp, tasks_pb2` and implements `TaskAPIToolHandler` with `tasks_pb2.*` request/response classes. +A pure Python MCP server example that opts into `python_handler: dataclass+protobuf`. +- **Raw `*_pb2` handlers**: the server imports `from proto import tasks_mcp_pb, tasks_pb2` and implements `TaskAPIToolHandler` with `tasks_pb2.*` request/response classes. - **Same generated registration**: `server.py` still registers tools through - `tasks_mcp.register_task_api_tools(server, TaskStore())`. -- **Dual-generation option**: projects that need both surfaces can use - `python_handler: dataclass+protobuf`; that keeps dataclass output in - `*_mcp.py` and adds raw protobuf output in `*_mcp_pb.py`. + `tasks_mcp_pb.register_task_api_tools(server, TaskStore())`. +- **Dual-generation option**: dataclass output stays in `*_mcp.py` and raw + protobuf output is generated in `*_mcp_pb.py`. - **Generator-owned contract**: schemas, ProtoJSON parsing, validation, annotations, structured output, and stdio behavior remain in the generated - `tasks_mcp.py` sidecar. + sidecars. ### [6_java_standalone](./6_java_standalone) (Java User Project) A standalone Java MCP server with its own Gradle build, `easyp.yaml`, protobuf From 2a9aabd3112af19a93d556b58f9d8ca805e1709c Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 18:36:51 +0300 Subject: [PATCH 72/74] docs: clarify Python dual handler example output --- examples/10_python_protobuf_standalone/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/10_python_protobuf_standalone/README.md b/examples/10_python_protobuf_standalone/README.md index 4429fab..dea42fd 100644 --- a/examples/10_python_protobuf_standalone/README.md +++ b/examples/10_python_protobuf_standalone/README.md @@ -26,9 +26,11 @@ python_runtime: google.protobuf python_handler: dataclass+protobuf ``` -`python_handler: protobuf` remains available for protobuf-only projects and -writes the raw handler sidecar to `proto/tasks_mcp.py`. This example uses -`python_handler: dataclass+protobuf` to demonstrate simultaneous generation. +For protobuf-only projects, `python_handler: protobuf` still writes the raw +handler sidecar to the normal `proto/tasks_mcp.py` path for backward +compatibility. This example uses `python_handler: dataclass+protobuf`, so the +dataclass sidecar uses `proto/tasks_mcp.py` and the raw protobuf sidecar uses +`proto/tasks_mcp_pb.py`. That generates: From 961401a5585c2bb89b7d0f10e09952430c621ad6 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 18:45:14 +0300 Subject: [PATCH 73/74] chore: ignore local IDE files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 248f20d..5006181 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ examples/6_java_standalone/build/ examples/7_kotlin_standalone/.gradle/ examples/7_kotlin_standalone/build/ .DS_Store +.idea/* From 4b0b24b86b7f2d45a23388124a1834939de16521 Mon Sep 17 00:00:00 2001 From: Khasbulat Abdullin Date: Thu, 14 May 2026 18:54:14 +0300 Subject: [PATCH 74/74] docs: update protoc-gen-mcp skill --- .github/skills/protoc-gen-mcp/SKILL.md | 449 +++++++++--------- .../references/troubleshooting.md | 67 ++- 2 files changed, 270 insertions(+), 246 deletions(-) diff --git a/.github/skills/protoc-gen-mcp/SKILL.md b/.github/skills/protoc-gen-mcp/SKILL.md index 4d2de76..ca054cb 100644 --- a/.github/skills/protoc-gen-mcp/SKILL.md +++ b/.github/skills/protoc-gen-mcp/SKILL.md @@ -1,268 +1,243 @@ --- name: protoc-gen-mcp -description: "Develop, test, and extend the protoc-gen-mcp protobuf-to-MCP code generator and runtime. Use when: modifying codegen, adding proto features, writing MCP tools, debugging schema generation, running easyp workflows, regenerating fixtures, updating golden snapshots, implementing new well-known types, adding field options, writing handler implementations." +description: "Develop, test, review, and extend the protoc-gen-mcp protobuf-first MCP generator for Go, Python, Kotlin, Java, TypeScript, and JavaScript-through-TypeScript examples. Use when modifying codegen/renderers, schema generation, ProtoJSON contracts, MCP options, Python handler modes, JVM/Node bindings, standalone examples, easyp workflows, golden snapshots, stdio tests, or project release/verification docs." --- # protoc-gen-mcp Development -Protobuf-first MCP tool generator and Go runtime. Converts annotated `.proto` -services into type-safe MCP tool bindings with JSON Schema validation. +This repository implements a protobuf-first MCP generator and runtime for Go, +Python, Kotlin, Java, and TypeScript MCP server bindings. Keep changes +decision-consistent with `AGENTS.md`; treat that file as the current source of +truth for supported features, layout, commands, and working rules. -## When to Use +## First Steps -- Modifying code generation logic or templates -- Adding support for new protobuf features or well-known types -- Writing or updating MCP field/method/service options -- Debugging JSON Schema generation or ProtoJSON round-trips -- Regenerating test fixtures or golden snapshots -- Implementing MCP tool handlers -- Working with easyp lint/generate workflows -- Reviewing or extending the runtime registration system +1. Run `git status --short --branch` before editing. Do not revert unrelated + changes. +2. Read `AGENTS.md` when the task touches supported languages, examples, + commands, public API, or verification status. +3. Keep `.planning/**` and other GSD artifacts local-only unless the user + explicitly changes that policy. +4. Prefer `easyp` workflows for repository generation and verification. Avoid + ad hoc direct `protoc` flows unless the target example intentionally uses + Maven/npm tooling. +5. Keep generator failures compile-time and explicit. Do not add manual + end-user tool-binding APIs in the MVP. -## Architecture Overview +## Architecture Map -``` -.proto file ──→ easyp (protoc-gen-go + protoc-gen-mcp) ──→ *.pb.go + *.mcp.go - │ - ┌───────────────┘ - ▼ - RegisterTools(server, impl) - │ - ▼ - mcpruntime.RegisterProtoTool - │ schema validation - │ ProtoJSON marshal/unmarshal - ▼ - MCP Server (stdio/SSE) -``` - -**Key modules:** - -| Module | Role | -|--------|------| -| `cmd/protoc-gen-mcp` | Protoc plugin entrypoint | -| `internal/codegen` | Generator logic: collects specs, emits `*.mcp.go` | -| `internal/schema` | Descriptor → JSON Schema conversion | -| `mcpruntime` | Runtime: registration, validation, ProtoJSON handling | -| `mcp/options/v1` | Custom protobuf options (`ServiceOptions`, `MethodOptions`, `FieldOptions`, etc.) | -| `internal/testproto` | Test fixtures and generated code | -| `internal/examplemcp` | Reusable example server + stdio smoke tests | +| Path | Role | +|---|---| +| `cmd/protoc-gen-mcp` | `--mcp_out` protoc plugin entrypoint and option dispatch | +| `internal/codegen` | Shared semantic model and language renderers | +| `internal/codegen/render_python.go` | Python dataclass/protobuf/dual handler generation | +| `internal/codegen/jvm_*.go` | SDK-neutral JVM model, naming, and collection | +| `internal/codegen/render_java.go` | Java low-level official SDK sidecar renderer | +| `internal/codegen/render_kotlin.go` | Kotlin official SDK sidecar renderer | +| `internal/codegen/render_typescript.go` | TypeScript official SDK + Protobuf-ES renderer | +| `internal/schema` | Descriptor-to-JSON-Schema conversion and ProtoJSON contract shape | +| `internal/pythontest` | Hermetic Python virtualenv bootstrap for Go tests | +| `internal/examplemcp` | Reusable stdio server checks for Go/Python/JVM examples | +| `examples/5_python_standalone` | Dataclass Python standalone project | +| `examples/10_python_protobuf_standalone` | Dual `dataclass+protobuf` Python standalone project | +| `examples/6_java_standalone` | Java standalone project | +| `examples/7_kotlin_standalone` | Kotlin standalone project | +| `examples/8_typescript_standalone` | TypeScript standalone project | +| `examples/9_javascript_standalone` | JavaScript consuming compiled TypeScript output | +| `examples/jvm` | Gradle compile/install/stdin proof for generated JVM sidecars | +| `examples/node/sdk-spike` | Pinned Node SDK, Protobuf-ES, Ajv compile gate | +| `testdata/golden` | Golden snapshots for generated Go/Python/JVM/TypeScript files | + +## Generation Targets + +| Target | Plugin Options | Generated Public API | +|---|---|---| +| Go | `lang=go` | `ToolHandler`, `RegisterTools(server, impl, opts...) error` | +| Python dataclass | `lang=python,python_runtime=google.protobuf` or `python_handler=dataclass` | `*_mcp.py`, dataclasses, `UNSET`, oneof wrappers, mapper helpers, `register__tools(...)` | +| Python protobuf | `lang=python,python_handler=protobuf` | `*_mcp.py` with raw `*_pb2` handler protocol and identity converters | +| Python dual | `lang=python,python_handler=dataclass+protobuf` | dataclass sidecar in `*_mcp.py` plus raw protobuf sidecar in `*_mcp_pb.py` | +| Kotlin | `lang=kotlin` | `ToolHandler`, `registerTools(server, impl, namespace = null)` | +| Java | `lang=java` | top-level `Mcp` class, nested `ToolHandler`, `registerTools(...)` | +| TypeScript | `lang=typescript` | `*_mcp.ts`, typed `ToolHandler`, `registerTools(server, impl, namespace?)` | +| JavaScript | no direct `lang=javascript` in v1.1 | consume compiled TypeScript `.js` plus `.d.ts` output | + +## Core Invariants + +- Support only `proto3` and unary request/response MCP tools. +- JSON contract is ProtoJSON-first for every target. +- Tool input requiredness is schema policy, not protobuf `required`. +- Singular non-optional fields are schema-required by default. +- `optional`, `repeated`, `map`, `oneof`, and explicitly optional fields are + not schema-required and must accept explicit JSON `null`. +- Recursive messages use `$defs`/`$ref`; schemas include useful examples for + complex ProtoJSON shapes. +- Generated tool names must not contain dots. Join namespace and method with + underscores and normalize dots to underscores. +- Unknown `protoc-gen-mcp` params, streaming RPCs, proto2, and unsupported + `google.protobuf.*` message types must fail fast. +- Non-Go targets must not require users to add Go-specific proto metadata just + to generate Python/JVM/TypeScript bindings. + +For detailed schema behavior, read +`references/schema-generation.md`. For failure diagnosis, read +`references/troubleshooting.md`. + +## Common Workflows + +### Modify a Renderer + +1. Identify whether the change belongs in shared `FileModel`/schema metadata or + in a target-specific renderer. +2. Reuse existing schema JSON, annotations, icons, and ProtoJSON semantics. + Do not recompute schema meaning inside language renderers. +3. Update focused contract tests for the target before or with implementation: + `*_contract_test.go`, `typescript_*_test.go`, Python renderer tests, or Go + golden tests. +4. Regenerate affected fixtures through `easyp`, then refresh matching golden + snapshots in `testdata/golden`. +5. Run the narrow target tests first, then broader tests if the change affects + shared behavior. + +### Modify Python Handler Modes + +1. Keep dataclass mode the default. +2. Preserve protobuf-only compatibility: `python_handler=protobuf` writes raw + handlers to `*_mcp.py`. +3. Preserve dual output: `python_handler=dataclass+protobuf` writes dataclass + handlers to `*_mcp.py` and raw handlers to `*_mcp_pb.py`. +4. Use `internal/pythontest` for runtime tests so Go tests do not depend on + globally installed Python packages. +5. Update `examples/5_python_standalone` or + `examples/10_python_protobuf_standalone` when behavior changes user-visible + generated output. + +### Modify JVM Support + +1. Keep `internal/codegen/jvm_*.go` SDK-neutral. Java and Kotlin SDK APIs differ; + share semantic collection, not SDK registration code. +2. Java uses low-level official SDK wiring through + `McpServerTransportProvider.setSessionFactory(...)`. +3. Kotlin uses `io.modelcontextprotocol:kotlin-sdk-server` low-level server + helpers. +4. Validate with focused Java/Kotlin codegen tests and the Gradle gate in + `examples/jvm`. + +### Modify TypeScript or JavaScript Support + +1. TypeScript targets `@modelcontextprotocol/sdk`, Protobuf-ES `_pb.ts`, and + strict NodeNext import rules. +2. Keep generated imports `.js`-suffixed and split type/value imports under + `verbatimModuleSyntax`. +3. JavaScript support is consumption of compiled TypeScript output; do not add + direct `lang=javascript` without revising the MVP decision. +4. Validate with `examples/node/sdk-spike`, focused TypeScript codegen tests, + and standalone Node stdio tests. + +### Add or Change MCP Options + +1. Edit `mcp/options/v1/options.proto`; keep its direct `go_package`. +2. Regenerate shipped options with `easyp --cfg easyp.yaml generate -p mcp -r .`. +3. Update metadata extraction and schema/rendering only where the option + actually applies. +4. Add fixture coverage in `internal/testproto` and refresh goldens. + +### Check a Standalone Example with MCP Inspector + +1. Build or set up the example first, for example: + `cd examples/10_python_protobuf_standalone && make setup`. +2. Start Inspector from the example directory: + `npx -y @modelcontextprotocol/inspector .venv/bin/python server.py`. +3. Open the printed Inspector URL, connect, run `List Tools`, and call at least + one state-changing/read tool plus health when available. +4. Confirm Inspector reports `Tool Result: Success`, output schema validity, + and structured/text parity. +5. Stop the Inspector process when done. ## Essential Commands ```bash -# Lint shipped API -easyp --cfg easyp.yaml lint -p mcp -r . +# Validate configs +easyp --cfg easyp.yaml validate-config +easyp --cfg easyp.test.yaml validate-config -# Generate shipped API (options.pb.go) +# Lint/generate shipped options +easyp --cfg easyp.yaml lint -p mcp -r . easyp --cfg easyp.yaml generate -p mcp -r . -# Lint test fixtures +# Lint/generate test fixtures easyp --cfg easyp.test.yaml lint -p internal/testproto -r . - -# Generate test fixtures (*.mcp.go + *.pb.go) easyp --cfg easyp.test.yaml generate -p internal/testproto -r . -# Run all tests +# Generate standalone examples +cd examples && make generate + +# Full Go test suite go test ./... -# Build plugin binary +# Build plugin go build ./cmd/protoc-gen-mcp - -# Build & run example server -go build -o example-mcp-server ./cmd/example-mcp-server/main.go -./example-mcp-server ``` -## Development Procedures - -### Procedure 1: Modify Code Generation - -1. Edit `internal/codegen/generator.go` or `internal/codegen/metadata.go` -2. If schema logic changes, also update `internal/schema/schema.go` -3. Regenerate test fixtures: - ```bash - easyp --cfg easyp.test.yaml generate -p internal/testproto -r . - ``` -4. Update golden snapshot — copy the regenerated file: - ```bash - cp internal/testproto/example/v1/example.mcp.go testdata/golden/example.mcp.go.golden - ``` -5. Run tests: - ```bash - go test ./... - ``` - -### Procedure 2: Add a New Protobuf Feature or Well-Known Type - -1. Add the type handling in `internal/schema/schema.go` (field → JSON Schema mapping) -2. If the type needs special codegen, update `internal/codegen/generator.go` -3. Add test coverage in `internal/testproto/example/v1/example.proto`: - - Add fields to existing request/response messages - - Or create a new RPC method for the feature -4. Regenerate + update golden: - ```bash - easyp --cfg easyp.test.yaml generate -p internal/testproto -r . - cp internal/testproto/example/v1/example.mcp.go testdata/golden/example.mcp.go.golden - ``` -5. Add schema validation test in `internal/testproto/example/v1/example_schema_test.go` -6. If the feature affects the stdio test, update `internal/examplemcp/server.go` and `stdio_test.go` -7. Run full test suite: `go test ./...` -8. Update `AGENTS.md` Supported Features section - -### Procedure 3: Add or Modify MCP Options - -1. Edit `mcp/options/v1/options.proto` -2. Regenerate shipped API: - ```bash - easyp --cfg easyp.yaml generate -p mcp -r . - ``` -3. Update metadata extraction in `internal/codegen/metadata.go` -4. Update schema generation in `internal/schema/schema.go` if the option affects schemas -5. Add test proto fields using the new option in `internal/testproto/example/v1/example.proto` -6. Regenerate test fixtures + golden -7. Run: `go test ./...` - -### Procedure 4: Write a New MCP Tool (End-User Flow) - -1. Define service and messages in a `.proto` file: - ```proto - service MyService { - option (mcp.options.v1.service) = { namespace: "my" }; - rpc DoThing(DoThingRequest) returns (DoThingResponse) { - option (mcp.options.v1.method) = { - title: "Do thing" - description: "Does the thing." - }; - }; - } - ``` -2. Annotate fields with `mcp.options.v1.field` for descriptions, examples, validation -3. Generate with easyp → produces `*.mcp.go` -4. Implement the `MyServiceToolHandler` interface -5. Register and serve: - ```go - server := mcp.NewServer(impl, nil) - myservice.RegisterMyServiceTools(server, handler) - server.Run(ctx, &mcp.StdioTransport{}) - ``` - -### Procedure 5: Debug Schema Generation Issues - -1. Check the generated JSON Schema constant in `*.mcp.go`: - - Look for `__ToolSpecInputSchemaJSON` -2. Parse the JSON and validate structure: - - `required` array matches expectations (singular non-optional fields) - - Nullable fields have proper `type: ["", "null"]` or `oneOf` with null - - `$defs`/`$ref` present for recursive or nested messages -3. Check `internal/schema/schema.go` for the field type mapping -4. Run the specific schema test: - ```bash - go test ./internal/testproto/example/v1/ -run TestSchema - ``` -5. For runtime validation issues, check `mcpruntime/register.go` schema validation logic - -## Key Invariants - -### Requiredness Policy -| Proto Field Pattern | Required in MCP Schema? | -|---|---| -| `string name = 1` | YES (singular, non-optional) | -| `optional string name = 1` | NO | -| `repeated string names = 1` | NO | -| `map m = 1` | NO | -| `oneof choice { ... }` | NO (unless `mcp.options.v1.oneof.required = true`) | - -### Nullability -Non-required fields accept explicit JSON `null` in generated schemas. -This ensures MCP clients with cached `inputSchema` remain compatible with -ProtoJSON unset semantics. - -### Tool Naming -- Pattern: `{namespace}_{MethodName}` -- Dots in namespace are normalized to underscores -- Example: namespace `example` + method `CreateReport` → `example_CreateReport` - -### Fail-Fast Rules (Compile-Time Errors) -- Proto2 syntax → generation error -- Streaming RPC (client/server/bidi) → generation error -- Unsupported `google.protobuf.*` types → generation error - -### JSON Schema Embedding -Schema JSON constants are emitted as **interpreted Go string literals** -(not raw backtick strings) so proto comments containing backticks don't -break generated code. - -## Proto Options Quick Reference - -```proto -import "mcp/options/v1/options.proto"; - -// Service-level -option (mcp.options.v1.service) = { - namespace: "myapi" - description: "My API tools." -}; - -// Method-level -option (mcp.options.v1.method) = { - name: "CustomName" // override tool method name - title: "Human Title" - description: "What this tool does." - hidden: true // exclude from tool list - annotations: { - read_only_hint: true - destructive_hint: false - idempotent_hint: true - } -}; - -// Field-level -(mcp.options.v1.field) = { - description: "Field purpose." - examples: [{ string_value: "example" }] - default_value: { number_value: 42 } - pattern: "^[A-Z]" - format: "email" - min_length: 1 - max_length: 255 - minimum: 0 - maximum: 100 - min_items: 1 - max_items: 50 - unique_items: true - read_only: true -}; - -// Message-level -option (mcp.options.v1.message) = { - title: "My Message" - description: "What this message represents." -}; - -// Enum-level -option (mcp.options.v1.enum) = { title: "Status" }; - -// Enum value: hide sentinel zero-value -UNSPECIFIED = 0 [(mcp.options.v1.enum_value) = { hidden: true }]; - -// Oneof-level -option (mcp.options.v1.oneof) = { required: true }; +## Focused Verification Commands + +```bash +# Python handler option and renderer contracts +go test ./internal/codegen -run 'TestParseOptions|TestGenerate_PythonMultipleHandlers|TestPythonRenderer_EmitsDataclassPublicAPI|TestPythonRenderer_EmitsProtobufHandlerPublicAPI|TestPythonRenderer_ProtobufHandlerImportsCrossFileProtobufModules|TestGenerate_PythonProtobufHandlerSkipsCrossFilePublicTypeModules' -count=1 + +# Java contracts and golden output +go test ./internal/codegen -run 'TestJavaContract_.*|TestGenerateJavaExampleGolden|TestGenerateJavaExampleHandlerCompileSmoke|TestGenerate_JavaTargetEmitsOutput' -count=1 + +# Kotlin contracts and golden output +go test ./internal/codegen -run 'TestGenerateKotlinExampleGolden|TestKotlinContract_.*' -count=1 + +# TypeScript semantic model, renderer, golden, and NodeNext compile smoke +go test ./internal/codegen -run 'TestTypeScriptModel|TestTypeScriptNames|TestTypeScriptContract|TestGenerateTypeScript|TestGenerate_TypeScript|TestTypeScriptGeneratedPublicAPICompilesUnderNodeNext' -count=1 + +# Generated Node stdio verification +go test ./internal/codegen -run 'TestTypeScriptGeneratedNodeServer.*OverStdio|TestTypeScriptGeneratedNodeServerRejectsInvalid(Input|Output)OverStdio' -count=1 + +# JVM compile/install gates +gradle --no-daemon -p examples/jvm :java-server:compileJava :kotlin-server:compileKotlin +gradle --no-daemon -p examples/jvm :java-server:installDist :kotlin-server:installDist + +# Standalone examples +cd examples/5_python_standalone && make setup && make run +cd examples/10_python_protobuf_standalone && make setup && make run +cd examples/6_java_standalone && make build +cd examples/7_kotlin_standalone && make build +cd examples/8_typescript_standalone && make build && make run +cd examples/9_javascript_standalone && make build && make run + +# Standalone stdio tests +go test ./examples -run 'TestStandalone(TypeScript|JavaScript)ExampleOverStdio' -count=1 +go test ./examples -run 'TestStandalonePython(Protobuf)?ExampleOverStdio|TestPythonExamplesOverStdio' -count=1 + +# Installed JVM stdio tests +go test ./internal/examplemcp -run 'TestJava.*OverStdio' -count=1 +go test ./internal/examplemcp -run 'TestKotlin.*OverStdio' -count=1 ``` -## Testing Matrix +## Golden Snapshot Notes -| Test Layer | Location | What it Validates | -|---|---|---| -| Golden snapshot | `testdata/golden/` | Generated `*.mcp.go` stability | -| Schema validation | `example_schema_test.go` | JSON Schema correctness for all field types | -| Property tests | `internal/codegen/` (rapid) | Edge cases in metadata/property generation | -| Stdio smoke test | `internal/examplemcp/stdio_test.go` | End-to-end: spawn server, list tools, call tools, validate responses | +- Go golden: `testdata/golden/example.mcp.go.golden`. +- Python goldens include dataclass/protobuf/dual handler outputs. +- Java golden: `testdata/golden/example_mcp.java.golden`. +- Kotlin golden: `testdata/golden/example_mcp.kt.golden`. +- TypeScript golden: `testdata/golden/example_mcp.ts.golden`. +- Regenerate fixtures through `easyp.test.yaml`; do not manually patch generated + output unless diagnosing a renderer bug. ## Common Mistakes to Avoid -- **Don't regenerate with raw protoc** — always use `easyp` configs -- **Don't forget golden snapshot** after regenerating test fixtures -- **Don't add streaming RPCs** — MVP is unary-only, generator will reject them -- **Don't use `required` proto label** — requiredness is a schema policy, not proto label -- **Don't skip AGENTS.md updates** when changing features, layout, or commands +- Do not assume the project is Go-only; most changes must preserve Python, JVM, + and TypeScript contracts. +- Do not make Go tests depend on globally installed `protoc` or Python + packages. Use `protocompile` and `internal/pythontest` patterns. +- Do not add direct `lang=javascript` without changing the documented MVP rule. +- Do not collapse Python dual output into one module; `*_mcp.py` and + `*_mcp_pb.py` have intentional compatibility meaning. +- Do not use SDK `addTool` shortcuts for JVM generated bindings; the project + owns low-level protocol mapping and validation. +- Do not commit `.planning/**` artifacts unless the user explicitly asks. +- Do not forget to update `AGENTS.md` when technology choices, layout, + supported features, public API, or verification commands change. diff --git a/.github/skills/protoc-gen-mcp/references/troubleshooting.md b/.github/skills/protoc-gen-mcp/references/troubleshooting.md index 994160f..80ca6d2 100644 --- a/.github/skills/protoc-gen-mcp/references/troubleshooting.md +++ b/.github/skills/protoc-gen-mcp/references/troubleshooting.md @@ -15,8 +15,17 @@ - **Fix**: Check AGENTS.md for supported well-known types. Use a supported alternative or add support in `internal/schema/schema.go`. ### Generated code has compile errors after regeneration -- **Cause**: Usually a mismatch between `*.pb.go` and `*.mcp.go` -- **Fix**: Ensure both `protoc-gen-go` and `protoc-gen-mcp` are regenerated together via `easyp generate`. +- **Cause**: Usually a mismatch between protobuf output and MCP sidecar output. +- **Fix**: Regenerate the affected package through the matching `easyp` config, + then refresh the relevant golden snapshot. Do not patch generated files by + hand except while diagnosing the renderer. + +### Unknown `protoc-gen-mcp` parameter +- **Cause**: Plugin params are parsed through a typed single-source option path. +- **Fix**: Use supported params only: + `lang=go|python|kotlin|java|typescript`, + `python_runtime=google.protobuf|betterproto|grpclib`, and + `python_handler=dataclass|protobuf|dataclass+protobuf`. ## Test Failures @@ -25,8 +34,11 @@ - **Fix**: ```bash easyp --cfg easyp.test.yaml generate -p internal/testproto -r . - cp internal/testproto/example/v1/example.mcp.go testdata/golden/example.mcp.go.golden ``` + Then refresh the matching file under `testdata/golden/` for the language that + changed, such as `example.mcp.go.golden`, `example_mcp.py.golden`, + `example_mcp_pb.py.golden`, `example_mcp.java.golden`, + `example_mcp.kt.golden`, or `example_mcp.ts.golden`. ### Schema validation test failure - **Cause**: JSON Schema doesn't match expected structure for a field type @@ -36,6 +48,17 @@ - **Cause**: Tool list, schema, or response format changed - **Fix**: Check `internal/examplemcp/server.go` handler implementations match the current proto definition. Verify expected tool names and payloads in `stdio_test.go`. +### Go tests fail because `protoc` is missing +- **Cause**: A test or helper reintroduced shelling out to external `protoc`. +- **Fix**: Generator tests should build descriptor requests in-process through + `github.com/bufbuild/protocompile`. Local `go test ./...` must not require a + `protoc` binary in `PATH`. + +### Python tests fail because `protobuf` or `mcp` is missing globally +- **Cause**: A test bypassed the hermetic Python bootstrap. +- **Fix**: Use `internal/pythontest` so tests create an isolated virtualenv and + install pinned runtime dependencies instead of relying on global packages. + ## Runtime Issues ### "duplicate tool name" error at registration @@ -54,11 +77,32 @@ - `Timestamp` must be RFC 3339 format - `bytes` must be base64 encoded +### Python raw protobuf handlers are missing +- **Cause**: The example or Easyp config generated only dataclass mode. +- **Fix**: Use `python_handler=protobuf` for protobuf-only output in + `*_mcp.py`, or `python_handler=dataclass+protobuf` for dataclass output in + `*_mcp.py` plus raw protobuf output in `*_mcp_pb.py`. + +### JVM server compiles but behavior does not match Go/Python +- **Cause**: Renderer delegated schema or ProtoJSON behavior to SDK-level + helpers instead of the generator-owned low-level contract. +- **Fix**: Keep Java/Kotlin registration on the low-level request-handler seam + and reuse generated raw schema JSON plus ProtoJSON parse/marshal helpers. + +### TypeScript NodeNext compile errors +- **Cause**: Generated imports are missing `.js` specifiers or mix type/value + imports under `verbatimModuleSyntax`. +- **Fix**: Keep Protobuf-ES imports `.js`-suffixed and split `import type` + from value imports in `render_typescript.go`. + ## easyp Workflow Issues ### "plugin not found" during generation - **Cause**: `protoc-gen-mcp` binary not built or not in PATH -- **Fix**: The easyp config uses `go run ./cmd/protoc-gen-mcp` to invoke the plugin directly. Ensure `go` is available and the module dependencies are resolved (`go mod download`). +- **Fix**: Repository Easyp configs should invoke the plugin through the local + Go module. Standalone examples may build or install the local binary as part + of their Makefile/Gradle/npm flow; run the example's documented `make setup`, + `make generate`, or `make build` target first. ### Lint failures on options.proto - **Cause**: `go_package` declaration issue or import path mismatch @@ -66,8 +110,13 @@ ## Debugging Tips -1. **Inspect generated schema**: Find `__ToolSpecInputSchemaJSON` constant in `*.mcp.go`, paste JSON into a formatter -2. **Trace schema generation**: Add logging in `internal/schema/schema.go` `GenerateFieldSchema()` for the specific field type -3. **Test a single schema**: `go test ./internal/testproto/example/v1/ -run TestSchema -v` -4. **Test stdio interaction**: `go test ./internal/examplemcp/ -run TestStdio -v` -5. **Validate easyp configs**: `easyp --cfg easyp.yaml validate-config` and `easyp --cfg easyp.test.yaml validate-config` +1. **Inspect generated schema**: find the generated schema JSON constant in the + sidecar for the target language and paste JSON into a formatter. +2. **Trace schema generation**: inspect `internal/schema` first; renderers + should consume schema semantics instead of recomputing them. +3. **Test one target**: run the focused `internal/codegen` contract test for + Python, Java, Kotlin, or TypeScript before running `go test ./...`. +4. **Test stdio interaction**: use `internal/examplemcp` tests, standalone + `examples` tests, or MCP Inspector for a user-facing check. +5. **Validate easyp configs**: `easyp --cfg easyp.yaml validate-config` and + `easyp --cfg easyp.test.yaml validate-config`.