Annotate MCP tools with titles and hints

Claude and other MCP clients use title, readOnlyHint, and
destructiveHint to present reads, writes, and deletes accurately.
Add a title to every tool, mark missing delete/unlink/cancel/void
tools as destructive, and teach mcpgen to emit those annotations
(including destructiveHint: false for non-destructive writes).

Temporary third_party/mcpgen fork until title support lands
upstream.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Cursor Agent
2026-07-30 08:16:28 +00:00
parent b27d22db2d
commit 5b47a83160
32 changed files with 7962 additions and 3 deletions

View File

@@ -25,17 +25,33 @@ go generate ./pkg/server/api/mcp/v1
```yaml
tools:
- name: listThirdParties
title: List Third Parties
description: List all thirdParties for the organization
hints:
readonly: true
idempotent: true
destructive: false
inputSchema:
$ref: "#/components/schemas/ListThirdPartiesInput"
outputSchema:
$ref: "#/components/schemas/ListThirdPartiesOutput"
- name: deleteThirdParty
title: Delete Third Party
description: Delete a thirdParty
hints:
readonly: false
destructive: true
inputSchema:
$ref: "#/components/schemas/DeleteThirdPartyInput"
outputSchema:
$ref: "#/components/schemas/DeleteThirdPartyOutput"
```
`title` is the human-readable display name (emitted as MCP `title` /
`annotations.title`). `hints.readonly` and `hints.destructive` map to
`readOnlyHint` and `destructiveHint` so clients can distinguish reads, writes,
and deletes. Every delete/remove/unlink/cancel/void tool must set
`destructive: true`.
Input/output schemas reference `components/schemas`. Map custom Go types with the `go.probo.inc/mcpgen/type` extension:
```yaml

2
go.mod
View File

@@ -272,3 +272,5 @@ tool (
)
replace github.com/elimity-com/scim => github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3
replace go.probo.inc/mcpgen => ./third_party/mcpgen

2
go.sum
View File

@@ -651,8 +651,6 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.probo.inc/mcpgen v0.0.0-20260428172408-1496ba9b4619 h1:LHOdoF7kYRXFtSP97eWpF1dIf0dBLrunRLOeU/pXt9c=
go.probo.inc/mcpgen v0.0.0-20260428172408-1496ba9b4619/go.mod h1:HunWQGqLdMocExJh4tWaX7p+uRZ9GlKvBvOXHaFW6vM=
go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA=
go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=

File diff suppressed because it is too large Load Diff

20
third_party/mcpgen/LICENSE vendored Normal file
View File

@@ -0,0 +1,20 @@
Copyright 2025 Probo Inc
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
“Software”), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

381
third_party/mcpgen/README.md vendored Normal file
View File

@@ -0,0 +1,381 @@
# mcpgen
## Overview
mcpgen is a code generator for Model Context Protocol (MCP) servers in Go, inspired by [gqlgen](https://github.com/99designs/gqlgen).
mcpgen takes a schema-first approach to building MCP servers. Define your tools, resources, and prompts in a YAML configuration file with JSON Schema definitions, and mcpgen generates type-safe Go code including:
- Type-safe Go structs from JSON Schemas
- MCP server boilerplate with the official [go-sdk](https://github.com/modelcontextprotocol/go-sdk)
- Handler function stubs ready for your business logic
## Features
- **Schema-First Development**: Define MCP primitives (tools, resources, prompts) in YAML with JSON Schema
- **Type-Safe Code Generation**: Generate Go structs from JSON Schema Draft 2020-12
- **Custom Type Mapping**: Use your own Go types instead of generated ones (like gqlgen)
- **Omittable Fields**: Distinguish between "not set", "null", and "value" with `go.probo.inc/mcpgen/omittable` (like gqlgen's `@goField(omittable: true)`)
- **Official SDK Integration**: Uses the official `modelcontextprotocol/go-sdk`
- **Handler Preservation**: Regeneration preserves your handler implementations
- **gqlgen-Inspired**: Familiar workflow if you've used gqlgen
## Installation
```bash
go install go.probo.inc/mcpgen@latest
```
Or build from source:
```bash
git clone https://github.com/probo-inc/mcpgen
cd mcpgen
go build -o mcpgen
```
## Quick Start
### 1. Initialize a new project
```bash
mcpgen init my-mcp-server
cd my-mcp-server
```
This creates:
```
my-mcp-server/
├── mcpgen.yaml # Configuration file
├── schemas/ # JSON Schema definitions
│ └── example_input.json
├── main.go # Entry point
└── README.md
```
### 2. Define your MCP primitives
Edit `mcpgen.yaml`:
```yaml
server:
name: my-mcp-server
version: 1.0.0
tools:
- name: calculate
description: Perform arithmetic operations
input_schema: schemas/calculate_input.json
resources:
- uri: docs://readme
name: Project README
description: The project README file
mime_type: text/markdown
prompts:
- name: greeting
description: A friendly greeting
arguments:
- name: name
description: Name of person to greet
required: false
```
### 3. Create JSON Schemas
Define schemas in the `schemas/` directory:
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["add", "subtract", "multiply", "divide"]
},
"a": {
"type": "number",
"description": "First operand"
},
"b": {
"type": "number",
"description": "Second operand"
}
},
"required": ["operation", "a", "b"]
}
```
### 4. Generate code
```bash
mcpgen generate
```
This generates:
- `generated/models.go` - Type-safe Go structs
- `generated/server.go` - MCP server setup
- `generated/resolver.go` - Handler stubs (first time only)
### 5. Implement handlers
Edit `generated/resolver.go`:
```go
func (r *Resolver) Calculate(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, map[string]any, error) {
operation := args["operation"].(string)
a := args["a"].(float64)
b := args["b"].(float64)
var result float64
switch operation {
case "add":
result = a + b
case "subtract":
result = a - b
case "multiply":
result = a * b
case "divide":
if b == 0 {
return nil, nil, fmt.Errorf("division by zero")
}
result = a / b
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: fmt.Sprintf("Result: %f", result),
},
},
}, map[string]any{"result": result}, nil
}
```
### 6. Build and run
```bash
go mod init my-mcp-server
go mod tidy
go build -o server
./server
```
## Configuration Reference
### Server Configuration
```yaml
server:
name: my-server # Required: Server name
version: 1.0.0 # Required: Server version
```
### Code Generation Options
```yaml
exec:
filename: generated/server.go # Server code output
package: generated # Package name
model:
filename: generated/models.go # Models output
package: generated # Package name
resolver:
filename: generated/resolver.go # Resolver stubs output
type: Resolver # Resolver type name
package: generated # Package name
preserve_resolver: true # Don't overwrite on regeneration
```
### Tools
```yaml
tools:
- name: tool_name # Required: Tool identifier
description: Tool description # Optional: Human-readable description
input_schema: schemas/input.json # Required: JSON Schema for input
output_schema: schemas/output.json # Optional: JSON Schema for output
```
### Resources
Static resources:
```yaml
resources:
- uri: docs://readme # Required: Resource URI
name: README # Required: Display name
description: Project README # Optional
mime_type: text/markdown # Optional
```
Resource templates (dynamic URIs):
```yaml
resources:
- uri_template: users://{id}/profile # Required: URI template
name: User Profile # Required
description: User profile data # Optional
mime_type: application/json # Optional
uri_params: # Parameters from template
- name: id
type: string
description: User ID
```
### Prompts
```yaml
prompts:
- name: prompt_name # Required: Prompt identifier
description: Description # Optional
arguments: # Optional: Prompt arguments
- name: arg_name
description: Arg description
required: true
```
## Commands
### `mcpgen init [name]`
Initialize a new MCP server project with example configuration.
```bash
mcpgen init my-server
```
### `mcpgen generate`
Generate code from `mcpgen.yaml` configuration.
```bash
mcpgen generate
# Specify custom config file
mcpgen generate --config custom-config.yaml
```
### `mcpgen version`
Print mcpgen version.
```bash
mcpgen version
```
## How It Works
1. **Configuration Loading**: mcpgen reads your `mcpgen.yaml` file
2. **Schema Loading**: JSON Schemas are loaded and `$ref` references resolved
3. **Type Generation**: Go structs are generated from JSON Schemas
4. **Server Generation**: MCP server boilerplate is generated with tool/resource/prompt registration
5. **Resolver Generation**: Handler stubs are generated (only if they don't exist)
## MCP Primitives
### Tools
Tools let LLMs interact with external systems. Each tool has:
- **Name**: Unique identifier (alphanumeric, underscore, dash, dot)
- **Description**: What the tool does
- **Input Schema**: JSON Schema defining parameters (required)
- **Output Schema**: JSON Schema for result validation (optional)
### Resources
Resources provide context to LLMs via URIs:
- **Static Resources**: Fixed URI (e.g., `docs://readme`)
- **Resource Templates**: Dynamic URIs (e.g., `users://{id}/profile`)
### Prompts
Prompts are reusable templates for LLM interactions with optional arguments.
## Comparison with gqlgen
| Feature | gqlgen | mcpgen |
|---------|--------|--------|
| **Schema Language** | GraphQL SDL | JSON Schema |
| **Protocol** | GraphQL | MCP (JSON-RPC 2.0) |
| **Core Primitives** | Queries, Mutations, Subscriptions | Tools, Resources, Prompts |
| **Generation** | Resolvers, models | Handlers, models |
| **Schema-first** | ✅ | ✅ |
| **Preserve implementations** | ✅ | ✅ |
| **Type safety** | ✅ | ✅ |
## Custom Type Mapping
You can use your own Go types instead of generated ones, similar to gqlgen's model binding.
### Using Schema Annotations (Recommended)
Add `go.probo.inc/mcpgen/type` annotations in your JSON Schema:
```yaml
components:
schemas:
# Use time.Time for timestamps
Timestamp:
type: string
format: date-time
go.probo.inc/mcpgen/type: time.Time
# Use UUID package
UUID:
type: string
format: uuid
go.probo.inc/mcpgen/type: github.com/google/uuid.UUID
# Use your own domain models
User:
type: object
properties:
id:
type: string
name:
type: string
go.probo.inc/mcpgen/type: github.com/myorg/models.User
```
When you reference these schemas, mcpgen will:
- Skip generating types for them
- Use your custom types instead
- Automatically add necessary imports
See [docs/custom-types.md](docs/custom-types.md) for full documentation.
## Examples
See the `examples/` directory for complete working examples.
## Development
### Building
```bash
go build -o mcpgen
```
### Testing
```bash
go test ./...
```
## Contributing
Contributions welcome. Please submit a Pull Request.
## License
MIT License - see LICENSE file for details.
## Acknowledgments
- Inspired by [gqlgen](https://github.com/99designs/gqlgen)
- Uses the official [Model Context Protocol Go SDK](https://github.com/modelcontextprotocol/go-sdk)

12
third_party/mcpgen/THIRD_PARTY.md vendored Normal file
View File

@@ -0,0 +1,12 @@
# Vendored mcpgen
This is a temporary fork of [getprobo/mcpgen](https://github.com/getprobo/mcpgen)
(module `go.probo.inc/mcpgen`) with tool annotation improvements:
- `title` field on tools (emitted as `Tool.Title` and `ToolAnnotations.Title`)
- When `hints` are present, always emit annotations so write tools get
`readOnlyHint: false` and `destructiveHint: false`, distinguishing them from
deletes (`destructiveHint: true`)
`go.mod` replaces `go.probo.inc/mcpgen` with this directory. Once the same
changes land upstream, drop the replace and delete this tree.

21
third_party/mcpgen/go.mod vendored Normal file
View File

@@ -0,0 +1,21 @@
module go.probo.inc/mcpgen
go 1.25.3
require (
github.com/google/jsonschema-go v0.3.0
github.com/modelcontextprotocol/go-sdk v1.1.0
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
golang.org/x/mod v0.30.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
)

32
third_party/mcpgen/go.sum vendored Normal file
View File

@@ -0,0 +1,32 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,282 @@
package codegen
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/mcpgen/internal/config"
)
func TestGenerateWithCustomTypes(t *testing.T) {
specPath := filepath.Join("testdata", "custom_types.yaml")
spec, err := config.LoadMCPSpec(specPath)
require.NoError(t, err, "Failed to load spec")
cfg := &config.Config{
Spec: specPath,
Output: t.TempDir(),
Model: config.ModelConfig{
Package: "test",
Filename: "models.go",
},
Resolver: config.ResolverConfig{
Package: "test",
Filename: "resolver.go",
Type: "Resolver",
Preserve: false,
},
// No custom models in config - using go.probo.inc/mcpgen/type annotations
Models: config.ModelsConfig{
Models: map[string]config.TypeMapping{},
},
}
gen := New(cfg, spec)
if err := gen.loadSchemas(); err != nil {
t.Fatalf("Failed to load schemas: %v", err)
}
code, err := gen.typeGen.Generate("test")
require.NoError(t, err, "Failed to generate code")
codeStr := string(code)
customTypes := []string{"Timestamp", "UUID", "Decimal", "Metadata", "Duration"}
for _, typeName := range customTypes {
assert.NotContains(t, codeStr, "type "+typeName+" ")
}
regularTypes := []string{"Task", "OptionalFields", "Project", "UpdateTaskInput"}
for _, typeName := range regularTypes {
assert.Contains(t, codeStr, "type "+typeName)
}
expectedImports := []string{
"time",
"github.com/google/uuid",
"github.com/shopspring/decimal",
"json",
"go.probo.inc/mcpgen/mcp",
}
for _, imp := range expectedImports {
assert.Contains(t, codeStr, `"`+imp+`"`)
}
assert.Contains(t, codeStr, "ID uuid.UUID")
assert.Contains(t, codeStr, "CreatedAt time.Time")
assert.Contains(t, codeStr, "UpdatedAt *time.Time")
assert.Contains(t, codeStr, "mcp.Omittable[*string]")
assert.Contains(t, codeStr, "mcp.Omittable[*Status]")
assert.Contains(t, codeStr, "mcp.Omittable[*int]")
assert.Contains(t, codeStr, "mcp.Omittable[*[]string]")
}
func TestGenerateWithConfigBasedTypes(t *testing.T) {
specPath := filepath.Join("testdata", "config_based_types.yaml")
spec, err := config.LoadMCPSpec(specPath)
require.NoError(t, err, "Failed to load spec")
cfg := &config.Config{
Spec: specPath,
Output: t.TempDir(),
Model: config.ModelConfig{
Package: "test",
Filename: "models.go",
},
Resolver: config.ResolverConfig{
Package: "test",
Filename: "resolver.go",
Type: "Resolver",
Preserve: false,
},
// Custom models in config
Models: config.ModelsConfig{
Models: map[string]config.TypeMapping{
"Timestamp": {Model: "time.Time"},
"UUID": {Model: "github.com/google/uuid.UUID"},
"User": {Model: "github.com/myapp/models.User"},
},
},
}
gen := New(cfg, spec)
if err := gen.loadSchemas(); err != nil {
t.Fatalf("Failed to load schemas: %v", err)
}
code, err := gen.typeGen.Generate("test")
require.NoError(t, err, "Failed to generate code")
codeStr := string(code)
customTypes := []string{"Timestamp", "UUID", "User"}
for _, typeName := range customTypes {
assert.NotContains(t, codeStr, "type "+typeName+" ")
}
assert.Contains(t, codeStr, "type Event struct")
expectedImports := []string{
"time",
"github.com/google/uuid",
"github.com/myapp/models",
}
for _, imp := range expectedImports {
assert.Contains(t, codeStr, `"`+imp+`"`)
}
assert.Contains(t, codeStr, "ID uuid.UUID")
assert.Contains(t, codeStr, "CreatedAt time.Time")
assert.Contains(t, codeStr, "Owner *models.User")
}
func TestGenerateAllPrimitives(t *testing.T) {
specPath := filepath.Join("testdata", "all_primitives.yaml")
spec, err := config.LoadMCPSpec(specPath)
require.NoError(t, err, "Failed to load spec")
cfg := &config.Config{
Spec: specPath,
Output: t.TempDir(),
Model: config.ModelConfig{
Package: "test",
Filename: "models.go",
},
Resolver: config.ResolverConfig{
Package: "test",
Filename: "resolver.go",
Type: "Resolver",
Preserve: false,
},
Models: config.ModelsConfig{
Models: map[string]config.TypeMapping{},
},
}
gen := New(cfg, spec)
if err := gen.loadSchemas(); err != nil {
t.Fatalf("Failed to load schemas: %v", err)
}
code, err := gen.typeGen.Generate("test")
require.NoError(t, err, "Failed to generate code")
codeStr := string(code)
primitiveTypes := map[string]string{
"StringSchema": "type StringSchema string",
"NumberSchema": "type NumberSchema float64",
"IntegerSchema": "type IntegerSchema int",
"BooleanSchema": "type BooleanSchema bool",
"ArraySchema": "type ArraySchema []string",
}
for typeName, expectedDecl := range primitiveTypes {
if !containsString(codeStr, expectedDecl) {
t.Errorf("Should generate %q for %s", expectedDecl, typeName)
}
}
if !containsString(codeStr, "type ObjectSchema struct") {
t.Error("Should generate ObjectSchema as a struct")
}
if !containsString(codeStr, "type Person struct") {
t.Error("Should generate Person as a struct")
}
if !containsString(codeStr, "type Color string") {
t.Error("Should generate Color as string-based enum")
}
enumConstants := []string{"ColorRed", "ColorGreen", "ColorBlue", "ColorYellow"}
for _, constName := range enumConstants {
if !containsString(codeStr, constName) {
t.Errorf("Should generate enum constant %q", constName)
}
}
if len(code) == 0 {
t.Error("Generated code is empty")
}
}
func TestGeneratedCodeCompiles(t *testing.T) {
testCases := []struct {
name string
specFile string
config *config.Config
}{
{
name: "custom_types",
specFile: "custom_types.yaml",
config: &config.Config{
Model: config.ModelConfig{
Package: "test",
Filename: "models.go",
},
Models: config.ModelsConfig{
Models: map[string]config.TypeMapping{},
},
},
},
{
name: "config_based_types",
specFile: "config_based_types.yaml",
config: &config.Config{
Model: config.ModelConfig{
Package: "test",
Filename: "models.go",
},
Models: config.ModelsConfig{
Models: map[string]config.TypeMapping{
"Timestamp": {Model: "time.Time"},
"UUID": {Model: "github.com/google/uuid.UUID"},
"User": {Model: "github.com/myapp/models.User"},
},
},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
specPath := filepath.Join("testdata", tc.specFile)
spec, err := config.LoadMCPSpec(specPath)
if err != nil {
t.Fatalf("Failed to load spec: %v", err)
}
tc.config.Spec = specPath
tc.config.Output = t.TempDir()
tc.config.Resolver = config.ResolverConfig{
Package: "test",
Filename: "resolver.go",
Type: "Resolver",
Preserve: false,
}
gen := New(tc.config, spec)
if err := gen.loadSchemas(); err != nil {
t.Fatalf("Failed to load schemas: %v", err)
}
code, err := gen.typeGen.Generate("test")
if err != nil {
t.Fatalf("Failed to generate code: %v", err)
}
// The fact that Generate() succeeded means the code was formatted successfully
if len(code) == 0 {
t.Error("Generated code is empty")
}
})
}
}

View File

@@ -0,0 +1,191 @@
package codegen
import (
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"os"
"strings"
)
type HandlerInfo struct {
Name string
RecvType string
SourceCode string
IsOrphaned bool
}
type ResolverParser struct {
filePath string
fset *token.FileSet
file *ast.File
}
func NewResolverParser(filePath string) (*ResolverParser, error) {
fset := token.NewFileSet()
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil, fmt.Errorf("resolver file not found: %s", filePath)
}
file, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("failed to parse resolver file: %w", err)
}
return &ResolverParser{
filePath: filePath,
fset: fset,
file: file,
}, nil
}
func (p *ResolverParser) ExtractHandlers(resolverType string) (map[string]*HandlerInfo, error) {
handlers := make(map[string]*HandlerInfo)
// Extract from both old wrapper types and new direct Resolver type
allowedTypes := map[string]bool{
// Old wrapper types (for backward compatibility during migration)
"toolResolver": true,
"*toolResolver": true,
"promptResolver": true,
"*promptResolver": true,
"resourceResolver": true,
"*resourceResolver": true,
// New direct Resolver type
resolverType: true,
"*" + resolverType: true,
}
for _, decl := range p.file.Decls {
funcDecl, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
if funcDecl.Recv == nil {
continue
}
recvType := p.getReceiverType(funcDecl.Recv)
if !allowedTypes[recvType] {
continue
}
methodName := funcDecl.Name.Name
sourceCode, err := p.extractFunctionSource(funcDecl)
if err != nil {
return nil, fmt.Errorf("failed to extract source for %s: %w", methodName, err)
}
// Transform receiver type from old wrapper types to main Resolver type
sourceCode = TransformReceiverType(sourceCode, resolverType)
handlers[methodName] = &HandlerInfo{
Name: methodName,
RecvType: "*" + resolverType, // Always use main Resolver type
SourceCode: sourceCode,
IsOrphaned: false,
}
}
return handlers, nil
}
func (p *ResolverParser) getReceiverType(recv *ast.FieldList) string {
if recv == nil || len(recv.List) == 0 {
return ""
}
field := recv.List[0]
switch typ := field.Type.(type) {
case *ast.Ident:
return typ.Name
case *ast.StarExpr:
if ident, ok := typ.X.(*ast.Ident); ok {
return "*" + ident.Name
}
}
return ""
}
func (p *ResolverParser) extractFunctionSource(funcDecl *ast.FuncDecl) (string, error) {
var buf strings.Builder
cfg := printer.Config{
Mode: printer.UseSpaces | printer.TabIndent,
Tabwidth: 8,
}
if err := cfg.Fprint(&buf, p.fset, funcDecl); err != nil {
return "", err
}
return buf.String(), nil
}
// TransformReceiverType rewrites the receiver type in handler source code from old wrapper types
// (toolResolver, promptResolver, resourceResolver) to the main Resolver type
func TransformReceiverType(sourceCode, resolverType string) string {
// Replace old wrapper types with main Resolver type
sourceCode = strings.ReplaceAll(sourceCode, "*toolResolver)", "*"+resolverType+")")
sourceCode = strings.ReplaceAll(sourceCode, "*promptResolver)", "*"+resolverType+")")
sourceCode = strings.ReplaceAll(sourceCode, "*resourceResolver)", "*"+resolverType+")")
return sourceCode
}
func IdentifyOrphanedHandlers(existingHandlers map[string]*HandlerInfo, requiredHandlers []string) {
requiredSet := make(map[string]bool)
for _, name := range requiredHandlers {
requiredSet[name] = true
}
for name, handler := range existingHandlers {
if !requiredSet[name] {
handler.IsOrphaned = true
}
}
}
func FormatOrphanedHandlers(handlers map[string]*HandlerInfo) string {
var orphaned []*HandlerInfo
for _, handler := range handlers {
if handler.IsOrphaned {
orphaned = append(orphaned, handler)
}
}
if len(orphaned) == 0 {
return ""
}
var buf strings.Builder
buf.WriteString("\n\n// ==============================================================================\n")
buf.WriteString("// Orphaned Handlers\n")
buf.WriteString("// ==============================================================================\n")
buf.WriteString("// The following handlers were found in the resolver file but are no longer\n")
buf.WriteString("// defined in the MCP specification. They have been preserved here as comments\n")
buf.WriteString("// in case you need to reference or restore them.\n")
buf.WriteString("// ==============================================================================\n\n")
for _, handler := range orphaned {
buf.WriteString(fmt.Sprintf("// Orphaned: %s\n", handler.Name))
buf.WriteString("// Uncomment and update signature if you want to restore this handler.\n")
lines := strings.Split(handler.SourceCode, "\n")
for _, line := range lines {
if strings.TrimSpace(line) != "" {
buf.WriteString("// ")
}
buf.WriteString(line)
buf.WriteString("\n")
}
buf.WriteString("\n")
}
return buf.String()
}

View File

@@ -0,0 +1,484 @@
package codegen
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)
func TestNewResolverParser(t *testing.T) {
tests := []struct {
name string
content string
wantErr bool
setupFile bool
}{
{
name: "valid resolver file",
content: `package test
type Resolver struct{}
func (r *Resolver) GetUser(ctx context.Context) error {
return nil
}`,
setupFile: true,
wantErr: false,
},
{
name: "non-existent file",
content: "",
setupFile: false,
wantErr: true,
},
{
name: "invalid Go syntax",
content: `package test
func ( { // invalid syntax
}`,
setupFile: true,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var testFile string
if tt.setupFile {
tmpDir := t.TempDir()
testFile = filepath.Join(tmpDir, "resolver.go")
if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
} else {
testFile = filepath.Join(t.TempDir(), "nonexistent.go")
}
parser, err := NewResolverParser(testFile)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got nil")
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if parser == nil {
t.Error("Expected parser but got nil")
}
}
})
}
}
func TestExtractHandlers(t *testing.T) {
tests := []struct {
name string
content string
resolverType string
wantHandlers []string
wantErr bool
}{
{
name: "extract toolResolver handlers",
content: `package test
type toolResolver struct{}
func (r *toolResolver) HandleListTasks(ctx context.Context) error {
return nil
}
func (r *toolResolver) HandleCreateTask(ctx context.Context) error {
return nil
}
// Not a handler - no receiver
func HelperFunction() {}
`,
resolverType: "Resolver",
wantHandlers: []string{"HandleListTasks", "HandleCreateTask"},
wantErr: false,
},
{
name: "extract promptResolver handlers",
content: `package test
type promptResolver struct{}
func (r *promptResolver) HandleGetPrompt(ctx context.Context) error {
return nil
}
`,
resolverType: "Resolver",
wantHandlers: []string{"HandleGetPrompt"},
wantErr: false,
},
{
name: "extract resourceResolver handlers",
content: `package test
type resourceResolver struct{}
func (r *resourceResolver) HandleReadResource(ctx context.Context) error {
return nil
}
`,
resolverType: "Resolver",
wantHandlers: []string{"HandleReadResource"},
wantErr: false,
},
{
name: "mixed resolver types",
content: `package test
type toolResolver struct{}
type promptResolver struct{}
type resourceResolver struct{}
func (r *toolResolver) HandleTool(ctx context.Context) error {
return nil
}
func (r *promptResolver) HandlePrompt(ctx context.Context) error {
return nil
}
func (r *resourceResolver) HandleResource(ctx context.Context) error {
return nil
}
type OtherType struct{}
func (r *OtherType) NotAHandler(ctx context.Context) error {
return nil
}
`,
resolverType: "Resolver",
wantHandlers: []string{"HandleTool", "HandlePrompt", "HandleResource"},
wantErr: false,
},
{
name: "no handlers",
content: `package test
type Resolver struct{}
func HelperFunction() {}
`,
resolverType: "Resolver",
wantHandlers: []string{},
wantErr: false,
},
{
name: "pointer and value receivers",
content: `package test
type toolResolver struct{}
func (r toolResolver) HandleNonPointer(ctx context.Context) error {
return nil
}
func (r *toolResolver) HandlePointer(ctx context.Context) error {
return nil
}
`,
resolverType: "Resolver",
wantHandlers: []string{"HandleNonPointer", "HandlePointer"}, // Both are accepted
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "resolver.go")
if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser, err := NewResolverParser(testFile)
if err != nil {
t.Fatalf("Failed to create parser: %v", err)
}
handlers, err := parser.ExtractHandlers(tt.resolverType)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got nil")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if len(handlers) != len(tt.wantHandlers) {
t.Errorf("Expected %d handlers, got %d", len(tt.wantHandlers), len(handlers))
}
for _, wantName := range tt.wantHandlers {
if _, ok := handlers[wantName]; !ok {
t.Errorf("Expected handler %q not found", wantName)
}
}
for name, handler := range handlers {
if handler.Name != name {
t.Errorf("Handler name mismatch: got %q, want %q", handler.Name, name)
}
if handler.SourceCode == "" {
t.Errorf("Handler %q has empty source code", name)
}
if !strings.Contains(handler.RecvType, "Resolver") {
t.Errorf("Handler %q has unexpected receiver type: %q", name, handler.RecvType)
}
}
})
}
}
func TestIdentifyOrphanedHandlers(t *testing.T) {
tests := []struct {
name string
existingHandlers map[string]*HandlerInfo
requiredHandlers []string
wantOrphaned []string
}{
{
name: "no orphaned handlers",
existingHandlers: map[string]*HandlerInfo{
"HandleA": {Name: "HandleA"},
"HandleB": {Name: "HandleB"},
},
requiredHandlers: []string{"HandleA", "HandleB"},
wantOrphaned: []string{},
},
{
name: "one orphaned handler",
existingHandlers: map[string]*HandlerInfo{
"HandleA": {Name: "HandleA"},
"HandleB": {Name: "HandleB"},
"HandleC": {Name: "HandleC"},
},
requiredHandlers: []string{"HandleA", "HandleB"},
wantOrphaned: []string{"HandleC"},
},
{
name: "all orphaned",
existingHandlers: map[string]*HandlerInfo{
"HandleA": {Name: "HandleA"},
"HandleB": {Name: "HandleB"},
},
requiredHandlers: []string{},
wantOrphaned: []string{"HandleA", "HandleB"},
},
{
name: "new handlers required",
existingHandlers: map[string]*HandlerInfo{
"HandleA": {Name: "HandleA"},
},
requiredHandlers: []string{"HandleA", "HandleB", "HandleC"},
wantOrphaned: []string{},
},
{
name: "empty existing handlers",
existingHandlers: map[string]*HandlerInfo{},
requiredHandlers: []string{"HandleA"},
wantOrphaned: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
IdentifyOrphanedHandlers(tt.existingHandlers, tt.requiredHandlers)
var gotOrphaned []string
for name, handler := range tt.existingHandlers {
if handler.IsOrphaned {
gotOrphaned = append(gotOrphaned, name)
}
}
if len(gotOrphaned) != len(tt.wantOrphaned) {
t.Errorf("Expected %d orphaned handlers, got %d", len(tt.wantOrphaned), len(gotOrphaned))
}
orphanedSet := make(map[string]bool)
for _, name := range gotOrphaned {
orphanedSet[name] = true
}
for _, wantName := range tt.wantOrphaned {
if !orphanedSet[wantName] {
t.Errorf("Expected %q to be orphaned but it wasn't", wantName)
}
}
})
}
}
func TestFormatOrphanedHandlers(t *testing.T) {
tests := []struct {
name string
handlers map[string]*HandlerInfo
wantContains []string
isEmpty bool
}{
{
name: "single orphaned handler",
handlers: map[string]*HandlerInfo{
"HandleOldTask": {
Name: "HandleOldTask",
IsOrphaned: true,
SourceCode: `func (r *Resolver) HandleOldTask(ctx context.Context) error {
return nil
}`,
},
},
wantContains: []string{
"Orphaned Handlers",
"Orphaned: HandleOldTask",
"// func (r *Resolver) HandleOldTask",
},
isEmpty: false,
},
{
name: "multiple orphaned handlers",
handlers: map[string]*HandlerInfo{
"HandleA": {
Name: "HandleA",
IsOrphaned: true,
SourceCode: "func (r *Resolver) HandleA() {}",
},
"HandleB": {
Name: "HandleB",
IsOrphaned: true,
SourceCode: "func (r *Resolver) HandleB() {}",
},
},
wantContains: []string{
"Orphaned: HandleA",
"Orphaned: HandleB",
},
isEmpty: false,
},
{
name: "no orphaned handlers",
handlers: map[string]*HandlerInfo{
"HandleActive": {
Name: "HandleActive",
IsOrphaned: false,
SourceCode: "func (r *Resolver) HandleActive() {}",
},
},
wantContains: []string{},
isEmpty: true,
},
{
name: "empty handlers map",
handlers: map[string]*HandlerInfo{},
wantContains: []string{},
isEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatOrphanedHandlers(tt.handlers)
if tt.isEmpty {
if result != "" {
t.Errorf("Expected empty result, got: %q", result)
}
return
}
if result == "" {
t.Error("Expected non-empty result but got empty string")
return
}
for _, want := range tt.wantContains {
if !strings.Contains(result, want) {
t.Errorf("Result should contain %q but doesn't.\nGot: %s", want, result)
}
}
assert.Contains(t, result, "Orphaned Handlers", "Result should contain orphaned handlers header")
})
}
}
func TestGetReceiverType(t *testing.T) {
// but we can add a direct test using a sample AST if needed
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := `package test
type toolResolver struct{}
func (r *toolResolver) Method() {}
`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser, err := NewResolverParser(testFile)
require.NoError(t, err, "Failed to create parser")
handlers, err := parser.ExtractHandlers("Resolver")
require.NoError(t, err, "Failed to extract handlers")
if len(handlers) != 1 {
t.Fatalf("Expected 1 handler, got %d", len(handlers))
}
handler := handlers["Method"]
// After transformation, the receiver type should be *Resolver
if handler.RecvType != "*Resolver" {
t.Errorf("Expected receiver type '*Resolver' (after transformation), got %q", handler.RecvType)
}
}
func TestExtractFunctionSource(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := `package test
type toolResolver struct{}
func (r *toolResolver) HandleTest(ctx context.Context) error {
x := 42
return nil
}
`
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser, err := NewResolverParser(testFile)
require.NoError(t, err, "Failed to create parser")
handlers, err := parser.ExtractHandlers("Resolver")
require.NoError(t, err, "Failed to extract handlers")
handler := handlers["HandleTest"]
// After transformation, toolResolver should be changed to Resolver
if !strings.Contains(handler.SourceCode, "func (r *Resolver) HandleTest") {
t.Errorf("Source code should contain transformed function signature, got: %s", handler.SourceCode)
}
assert.Contains(t, handler.SourceCode, "return nil", "Source code should contain function body")
assert.Contains(t, handler.SourceCode, "x := 42", "Source code should contain function body statements")
}

View File

@@ -0,0 +1,58 @@
package {{.Package}}
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by mcpgen. DO NOT EDIT.
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
{{- if .Imports}}
{{- range .Imports}}
{{- if .Alias}}
{{.Alias}} "{{.Path}}"
{{- else}}
"{{.Path}}"
{{- end}}
{{- end}}
{{- end}}
)
{{- range .Tools}}
{{- if .HasInputType}}
func (r *{{$.ResolverType}}) {{.HandlerName}}Tool(ctx context.Context, req *mcp.CallToolRequest, input *{{.InputType}}) (*mcp.CallToolResult, {{if .HasOutputType}}{{.OutputType}}{{else}}map[string]any{{end}}, error) {
return nil, {{if .HasOutputType}}{{.OutputType}}{}{{else}}nil{{end}}, fmt.Errorf("{{.Name}} not implemented")
}
{{- else}}
func (r *{{$.ResolverType}}) {{.HandlerName}}Tool(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, {{if .HasOutputType}}{{.OutputType}}{{else}}map[string]any{{end}}, error) {
return nil, {{if .HasOutputType}}{{.OutputType}}{}{{else}}nil{{end}}, fmt.Errorf("{{.Name}} not implemented")
}
{{- end}}
{{- end}}
{{- if .HasResources}}
{{- range .Resources}}
func (r *{{$.ResolverType}}) {{.HandlerName}}Resource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
return nil, fmt.Errorf("{{.Name}} not implemented")
}
{{- end}}
{{- end}}
{{- if .HasPrompts}}
{{- range .Prompts}}
{{- if .HasArgsType}}
func (r *{{$.ResolverType}}) {{.HandlerName}}Prompt(ctx context.Context, req *mcp.GetPromptRequest, args {{.ArgsType}}) (*mcp.GetPromptResult, error) {
return nil, fmt.Errorf("{{.Name}} not implemented")
}
{{- else}}
func (r *{{$.ResolverType}}) {{.HandlerName}}Prompt(ctx context.Context, req *mcp.GetPromptRequest, args map[string]string) (*mcp.GetPromptResult, error) {
return nil, fmt.Errorf("{{.Name}} not implemented")
}
{{- end}}
{{- end}}
{{- end}}

View File

@@ -0,0 +1,22 @@
package {{.Package}}
// This file will NOT be regenerated automatically.
//
// It serves as a dependency injection container for your resolvers.
// Add any dependencies you need here (database connections, API clients, etc.)
// and they'll be available to all your tool, prompt, and resource resolvers.
// {{.ResolverType}} is the root resolver that holds dependencies for all MCP handlers
type {{.ResolverType}} struct {
// Add your dependencies here, for example:
// DB *sql.DB
// Cache *redis.Client
// APIClient *http.Client
}
// New{{.ResolverType}} creates a new resolver instance
func New{{.ResolverType}}() *{{.ResolverType}} {
return &{{.ResolverType}}{
// Initialize your dependencies here
}
}

View File

@@ -0,0 +1,175 @@
// Code generated by mcpgen. DO NOT EDIT.
package {{.Package}}
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
{{- if .Imports}}
{{- range .Imports}}
{{- if .Alias}}
{{.Alias}} "{{.Path}}"
{{- else}}
"{{.Path}}"
{{- end}}
{{- end}}
{{- end}}
mcputil "go.probo.inc/mcpgen/mcp"
)
// ResolverInterface defines the interface that must be implemented by the parent resolver
type ResolverInterface interface {
{{- range .Tools}}
{{.HandlerName}}Tool(ctx context.Context, req *mcp.CallToolRequest{{if .HasInputType}}, input *{{.InputType}}{{else}}, args map[string]any{{end}}) (*mcp.CallToolResult, {{if .HasOutputType}}{{.OutputType}}{{else}}map[string]any{{end}}, error)
{{- end}}
{{- if .HasResources}}
{{- range .Resources}}
{{.HandlerName}}Resource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error)
{{- end}}
{{- end}}
{{- if .HasPrompts}}
{{- range .Prompts}}
{{.HandlerName}}Prompt(ctx context.Context, req *mcp.GetPromptRequest{{if .HasArgsType}}, args {{.ArgsType}}{{else}}, args map[string]string{{end}}) (*mcp.GetPromptResult, error)
{{- end}}
{{- end}}
}
// New creates a new MCP server instance with all handlers registered.
// Returns a fully configured *mcp.Server ready to be used with any transport.
func New(resolver ResolverInterface, opts ...mcputil.Option) *mcp.Server {
o := mcputil.ApplyOptions(opts)
server := mcp.NewServer(
&mcp.Implementation{
Name: "{{.ServerName}}",
Version: "{{.ServerVersion}}",
},
nil,
)
registerToolHandlers(server, resolver, &o)
{{- if .HasResources}}
registerResourceHandlers(server, resolver)
{{- end}}
{{- if .HasPrompts}}
registerPromptHandlers(server, resolver)
{{- end}}
return server
}
func registerToolHandlers(server *mcp.Server, resolver ResolverInterface, opts *mcputil.Options) {
{{- range .Tools}}
{{- $hasAnnotations := or .HasHints .Title}}
mcp.AddTool(
server,
&mcp.Tool{
Name: "{{.Name}}",
{{- if .Title}}
Title: "{{.Title}}",
{{- end}}
Description: "{{.Description}}",
{{- if .HasInputType}}
InputSchema: {{.InputSchemaVar}},
{{- end}}
{{- if .HasOutputType}}
OutputSchema: {{.OutputSchemaVar}},
{{- end}}
{{- if $hasAnnotations}}
Annotations: &mcp.ToolAnnotations{
{{- if .Title}}
Title: "{{.Title}}",
{{- end}}
{{- if .Readonly}}
ReadOnlyHint: true,
{{- else if .HasHints}}
ReadOnlyHint: false,
DestructiveHint: boolPtr({{if .Destructive}}true{{else}}false{{end}}),
{{- end}}
{{- if .Idempotent}}
IdempotentHint: true,
{{- end}}
{{- if .OpenWorld}}
OpenWorldHint: boolPtr(true),
{{- end}}
},
{{- end}}
},
func(ctx context.Context, req *mcp.CallToolRequest, input {{if .HasInputType}}*{{.InputType}}{{else}}map[string]any{{end}}) (result *mcp.CallToolResult, output {{if .HasOutputType}}{{.OutputType}}{{else}}map[string]any{{end}}, err error) {
defer func() {
if r := recover(); r != nil {
err = opts.RecoverFunc(ctx, r)
}
}()
return resolver.{{.HandlerName}}Tool(ctx, req, input)
},
)
{{- end}}
}
func boolPtr(b bool) *bool {
return &b
}
{{- if .HasResources}}
func registerResourceHandlers(server *mcp.Server, resolver ResolverInterface) {
{{- range .Resources}}
{{- if .URI}}
server.AddResource(
&mcp.Resource{
URI: "{{.URI}}",
Name: "{{.Name}}",
Description: "{{.Description}}",
{{- if .MimeType}}
MIMEType: "{{.MimeType}}",
{{- end}}
},
resolver.{{.HandlerName}}Resource,
)
{{- else if .URITemplate}}
server.AddResourceTemplate(
&mcp.ResourceTemplate{
URITemplate: "{{.URITemplate}}",
Name: "{{.Name}}",
Description: "{{.Description}}",
{{- if .MimeType}}
MIMEType: "{{.MimeType}}",
{{- end}}
},
resolver.{{.HandlerName}}Resource,
)
{{- end}}
{{- end}}
}
{{- end}}
{{- if .HasPrompts}}
func registerPromptHandlers(server *mcp.Server, resolver ResolverInterface) {
{{- range .Prompts}}
mcputil.AddPrompt(
server,
&mcp.Prompt{
Name: "{{.Name}}",
Description: "{{.Description}}",
{{- if .Arguments}}
Arguments: []*mcp.PromptArgument{
{{- range .Arguments}}
{
Name: "{{.Name}}",
Description: "{{.Description}}",
Required: {{.Required}},
},
{{- end}}
},
{{- end}}
},
resolver.{{.HandlerName}}Prompt,
)
{{- end}}
}
{{- end}}

View File

@@ -0,0 +1,222 @@
info:
title: all-primitives-test
version: 1.0.0
description: Test all MCP primitives and JSON Schema features
components:
schemas:
# All JSON Schema types
StringSchema:
type: string
description: A string
NumberSchema:
type: number
description: A number
IntegerSchema:
type: integer
description: An integer
BooleanSchema:
type: boolean
description: A boolean
ArraySchema:
type: array
items:
type: string
description: An array of strings
ObjectSchema:
type: object
properties:
name:
type: string
value:
type: number
required: [name]
# Enum types
Color:
type: string
enum: [red, green, blue, yellow]
description: A color
# Nested objects
Address:
type: object
properties:
street:
type: string
city:
type: string
zipCode:
type: string
country:
type: string
required: [city, country]
Person:
type: object
properties:
name:
type: string
age:
type: integer
email:
type: string
format: email
address:
$ref: "#/components/schemas/Address"
favoriteColor:
$ref: "#/components/schemas/Color"
tags:
type: array
items:
type: string
metadata:
type: object
additionalProperties: true
required: [name]
# Nullable fields with anyOf
NullableFields:
type: object
properties:
nullableString:
anyOf:
- type: string
- type: "null"
nullableNumber:
anyOf:
- type: number
- type: "null"
nullableObject:
anyOf:
- $ref: "#/components/schemas/Address"
- type: "null"
# Complex nested structure
Organization:
type: object
properties:
id:
type: string
name:
type: string
members:
type: array
items:
$ref: "#/components/schemas/Person"
headquarters:
$ref: "#/components/schemas/Address"
founded:
type: string
format: date
required: [id, name]
tools:
# Tool with inline schema
- name: simple_tool
description: A simple tool with inline schema
inputSchema:
type: object
properties:
message:
type: string
count:
type: integer
required: [message]
# Tool with ref schema
- name: create_person
description: Create a person
inputSchema:
$ref: "#/components/schemas/Person"
# Tool with complex schema
- name: create_organization
description: Create an organization
inputSchema:
$ref: "#/components/schemas/Organization"
# Tool with enum
- name: set_color
description: Set a color
inputSchema:
type: object
properties:
color:
$ref: "#/components/schemas/Color"
required: [color]
# Tool with nullable fields
- name: update_fields
description: Update optional fields
inputSchema:
$ref: "#/components/schemas/NullableFields"
resources:
# Static resource
- uri: "docs://readme"
name: README
description: The README document
mimeType: text/markdown
readonly: true
# Resource with simple template
- uriTemplate: "person://{id}"
name: Person Resource
description: Get a person by ID
mimeType: application/json
readonly: true
schema:
$ref: "#/components/schemas/Person"
# Resource with multiple parameters
- uriTemplate: "org://{orgId}/member/{memberId}"
name: Organization Member
description: Get a member of an organization
mimeType: application/json
readonly: true
schema:
$ref: "#/components/schemas/Person"
# Resource with nested schema
- uriTemplate: "org://{id}"
name: Organization Resource
description: Get an organization by ID
mimeType: application/json
schema:
$ref: "#/components/schemas/Organization"
prompts:
# Prompt without arguments
- name: help
description: Get general help
# Prompt with optional arguments
- name: person_info
description: Get information about a person
arguments:
- name: personId
description: The person ID
required: true
- name: includeAddress
description: Include address in the response
required: false
- name: format
description: Output format
required: false
# Prompt with all required arguments
- name: compare_people
description: Compare two people
arguments:
- name: person1Id
description: First person ID
required: true
- name: person2Id
description: Second person ID
required: true

View File

@@ -0,0 +1,31 @@
// Code generated by mcpgen. DO NOT EDIT.
package test
import (
"github.com/google/uuid"
"github.com/myapp/models"
mcputil "go.probo.inc/mcpgen/mcp"
"time"
)
// Tool input schemas
var (
CreateEventToolInputSchema = mcputil.MustUnmarshalSchema(`{"type":"object","required":["id","name","createdAt"],"properties":{"createdAt":{"$ref":"#/components/schemas/Timestamp"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"owner":{"$ref":"#/components/schemas/User"}}}`)
)
// Event represents the schema
type Event struct {
Name string `json:"name"`
Owner models.User `json:"owner,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ID uuid.UUID `json:"id"`
}
// CreateEventInput represents the schema
type CreateEventInput struct {
Name string `json:"name"`
Owner models.User `json:"owner,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ID uuid.UUID `json:"id"`
}

View File

@@ -0,0 +1,45 @@
info:
title: config-based-test
version: 1.0.0
description: Test config-based custom type mapping
components:
schemas:
# These will be mapped via config, not go.probo.inc/mcpgen/type
Timestamp:
type: string
format: date-time
UUID:
type: string
format: uuid
User:
type: object
properties:
id:
type: string
name:
type: string
email:
type: string
format: email
required: [id, name]
Event:
type: object
properties:
id:
$ref: "#/components/schemas/UUID"
name:
type: string
owner:
$ref: "#/components/schemas/User"
createdAt:
$ref: "#/components/schemas/Timestamp"
required: [id, name, createdAt]
tools:
- name: create_event
inputSchema:
$ref: "#/components/schemas/Event"

View File

@@ -0,0 +1,185 @@
info:
title: custom-types-test
version: 1.0.0
description: Test all custom type mapping scenarios
components:
schemas:
# Standard library types with go.probo.inc/mcpgen/type
Timestamp:
type: string
format: date-time
description: A timestamp
go.probo.inc/mcpgen/type: time.Time
Duration:
type: string
description: A duration
go.probo.inc/mcpgen/type: time.Duration
# External package types
UUID:
type: string
format: uuid
description: A UUID
go.probo.inc/mcpgen/type: github.com/google/uuid.UUID
Decimal:
type: string
description: A decimal number
go.probo.inc/mcpgen/type: github.com/shopspring/decimal.Decimal
# JSON raw message
Metadata:
type: object
description: Raw JSON metadata
go.probo.inc/mcpgen/type: json.RawMessage
# Regular enum (should be generated)
Status:
type: string
enum: [pending, in_progress, completed, cancelled]
description: Task status
# Regular object (should be generated)
Task:
type: object
description: A task
properties:
id:
$ref: "#/components/schemas/UUID"
title:
type: string
description: Task title
status:
$ref: "#/components/schemas/Status"
createdAt:
$ref: "#/components/schemas/Timestamp"
updatedAt:
anyOf:
- $ref: "#/components/schemas/Timestamp"
- type: "null"
duration:
$ref: "#/components/schemas/Duration"
metadata:
$ref: "#/components/schemas/Metadata"
tags:
type: array
items:
type: string
description: Task tags
priority:
type: integer
description: Priority level
required: [id, title, status, createdAt]
# Object with all nullable custom types
OptionalFields:
type: object
properties:
optionalTimestamp:
anyOf:
- $ref: "#/components/schemas/Timestamp"
- type: "null"
optionalUUID:
anyOf:
- $ref: "#/components/schemas/UUID"
- type: "null"
optionalDecimal:
anyOf:
- $ref: "#/components/schemas/Decimal"
- type: "null"
# Nested objects
Project:
type: object
properties:
id:
$ref: "#/components/schemas/UUID"
name:
type: string
tasks:
type: array
items:
$ref: "#/components/schemas/Task"
createdAt:
$ref: "#/components/schemas/Timestamp"
required: [id, name, createdAt]
# Update input with omittable fields
UpdateTaskInput:
type: object
description: Input for partial task update
properties:
id:
$ref: "#/components/schemas/UUID"
description: Task ID to update
title:
anyOf:
- type: string
- type: "null"
description: New title (omit to keep unchanged, null to clear)
go.probo.inc/mcpgen/omittable: true
status:
anyOf:
- $ref: "#/components/schemas/Status"
- type: "null"
description: New status (omit to keep unchanged)
go.probo.inc/mcpgen/omittable: true
priority:
anyOf:
- type: integer
- type: "null"
description: New priority (omit to keep unchanged, null to clear)
go.probo.inc/mcpgen/omittable: true
tags:
anyOf:
- type: array
items:
type: string
- type: "null"
description: New tags (omit to keep unchanged, null to clear)
go.probo.inc/mcpgen/omittable: true
required: [id]
tools:
- name: create_task
description: Create a new task
inputSchema:
$ref: "#/components/schemas/Task"
- name: update_task
description: Update task fields (partial update with omittable fields)
inputSchema:
$ref: "#/components/schemas/UpdateTaskInput"
- name: create_project
description: Create a new project
inputSchema:
$ref: "#/components/schemas/Project"
resources:
- uriTemplate: "task://{id}"
name: Task Resource
description: Get a task by ID
mimeType: application/json
schema:
$ref: "#/components/schemas/Task"
- uriTemplate: "project://{id}"
name: Project Resource
description: Get a project by ID
mimeType: application/json
schema:
$ref: "#/components/schemas/Project"
prompts:
- name: task_summary
description: Generate a task summary
arguments:
- name: taskId
description: The task ID
required: true
- name: includeMetadata
description: Include metadata in summary
required: false

View File

@@ -0,0 +1,652 @@
package codegen
import (
"fmt"
"go/format"
"sort"
"strings"
"go.probo.inc/mcpgen/internal/schema"
)
type CustomTypeMapping struct {
GoType string
ImportPath string
IsPointer bool
}
type TypeGenerator struct {
schemas map[string]*schema.Schema
types map[string]string
enums map[string]string
imports map[string]bool
schemaVars map[string]string
customMappings map[string]*CustomTypeMapping
}
func NewTypeGenerator() *TypeGenerator {
return &TypeGenerator{
schemas: make(map[string]*schema.Schema),
types: make(map[string]string),
enums: make(map[string]string),
imports: make(map[string]bool),
schemaVars: make(map[string]string),
customMappings: make(map[string]*CustomTypeMapping),
}
}
func (g *TypeGenerator) AddCustomMapping(schemaName string, mapping *CustomTypeMapping) {
g.customMappings[schemaName] = mapping
}
func (g *TypeGenerator) AddSchema(name string, s *schema.Schema) {
g.schemas[name] = s
}
func (g *TypeGenerator) AddSchemaVar(name string, schemaJSON string) {
g.schemaVars[name] = schemaJSON
g.imports["go.probo.inc/mcpgen/mcp"] = true
}
func (g *TypeGenerator) Generate(packageName string) ([]byte, error) {
var buf strings.Builder
buf.WriteString("// Code generated by mcpgen. DO NOT EDIT.\n\n")
buf.WriteString(fmt.Sprintf("package %s\n\n", packageName))
// Sort schema names for deterministic output
schemaNames := make([]string, 0, len(g.schemas))
for name := range g.schemas {
schemaNames = append(schemaNames, name)
}
sort.Strings(schemaNames)
for _, name := range schemaNames {
s := g.schemas[name]
typeName := toGoTypeName(name)
if _, hasCustomMapping := g.customMappings[name]; hasCustomMapping {
continue
}
typeCode, err := g.generateType(typeName, s, 0)
if err != nil {
return nil, fmt.Errorf("failed to generate type for %s: %w", name, err)
}
if typeCode != "" && g.types[typeName] == "" {
g.types[typeName] = typeCode
}
}
if len(g.imports) > 0 {
buf.WriteString("import (\n")
// Sort imports for deterministic output
imports := make([]string, 0, len(g.imports))
for imp := range g.imports {
imports = append(imports, imp)
}
sort.Strings(imports)
for _, imp := range imports {
buf.WriteString(fmt.Sprintf("\t\"%s\"\n", imp))
}
buf.WriteString(")\n\n")
}
if len(g.schemaVars) > 0 {
buf.WriteString("// Tool input schemas\n")
buf.WriteString("var (\n")
// Sort schema var names for deterministic output
varNames := make([]string, 0, len(g.schemaVars))
for varName := range g.schemaVars {
varNames = append(varNames, varName)
}
sort.Strings(varNames)
for _, varName := range varNames {
schemaJSON := g.schemaVars[varName]
buf.WriteString(fmt.Sprintf("\t%s = mcp.MustUnmarshalSchema(`%s`)\n", varName, schemaJSON))
}
buf.WriteString(")\n\n")
}
// Sort enum names for deterministic output
enumNames := make([]string, 0, len(g.enums))
for enumName := range g.enums {
enumNames = append(enumNames, enumName)
}
sort.Strings(enumNames)
for _, enumName := range enumNames {
enumCode := g.enums[enumName]
buf.WriteString(enumCode)
buf.WriteString("\n\n")
}
written := make(map[string]bool)
// Sort schema names for deterministic output (second pass)
for _, name := range schemaNames {
typeName := toGoTypeName(name)
if typeCode := g.types[typeName]; typeCode != "" {
buf.WriteString(typeCode)
buf.WriteString("\n\n")
written[typeName] = true
}
}
// Sort type names for deterministic output
typeNames := make([]string, 0, len(g.types))
for typeName := range g.types {
typeNames = append(typeNames, typeName)
}
sort.Strings(typeNames)
for _, typeName := range typeNames {
typeCode := g.types[typeName]
if !written[typeName] && typeCode != "" {
buf.WriteString(typeCode)
buf.WriteString("\n\n")
}
}
formatted, err := format.Source([]byte(buf.String()))
if err != nil {
return nil, fmt.Errorf("failed to format generated code: %w\n%s", err, buf.String())
}
return formatted, nil
}
func (g *TypeGenerator) generateType(name string, s *schema.Schema, depth int) (string, error) {
schemaType := schema.GetType(s)
if schemaType == "" && s.Properties != nil && len(s.Properties) > 0 {
return g.generateStruct(name, s, depth)
}
if schemaType == "" && s.Properties == nil {
return "", fmt.Errorf("unsupported schema type: %q (no type and no properties for %s)", schemaType, name)
}
if len(s.Enum) > 0 {
return g.generateEnum(name, s)
}
switch schemaType {
case "object":
return g.generateStruct(name, s, depth)
case "array":
return g.generateArrayType(name, s, depth)
case "string":
if depth == 0 {
return g.generatePrimitiveTypeAlias(name, s, "string")
}
return "", nil
case "number":
if depth == 0 {
return g.generatePrimitiveTypeAlias(name, s, "float64")
}
return "", nil
case "integer":
if depth == 0 {
return g.generatePrimitiveTypeAlias(name, s, "int")
}
return "", nil
case "boolean":
if depth == 0 {
return g.generatePrimitiveTypeAlias(name, s, "bool")
}
return "", nil
default:
if len(s.Properties) > 0 {
return g.generateStruct(name, s, depth)
}
return "", fmt.Errorf("unsupported schema type: %s", schemaType)
}
}
func (g *TypeGenerator) generateStruct(name string, s *schema.Schema, depth int) (string, error) {
var buf strings.Builder
if s.Description != "" {
buf.WriteString(formatComment(s.Description, ""))
} else if s.Title != "" {
buf.WriteString(formatComment(s.Title, ""))
} else {
buf.WriteString(fmt.Sprintf("// %s represents the schema\n", name))
}
buf.WriteString(fmt.Sprintf("type %s struct {\n", name))
// Sort property names for deterministic output
propNames := make([]string, 0, len(s.Properties))
for propName := range s.Properties {
propNames = append(propNames, propName)
}
sort.Strings(propNames)
for _, propName := range propNames {
propSchema := s.Properties[propName]
fieldName := toGoFieldName(propName)
hint := name + fieldName
isRequired := schema.IsRequired(s, propName)
isOmittable := schema.IsOmittable(propSchema)
// Validate that omittable is only used on nullable fields
if isOmittable {
isNullable, _ := isNullableType(propSchema)
if !isNullable {
return "", fmt.Errorf("field %s.%s has omittable annotation but is not nullable (omittable only works with nullable fields)", name, propName)
}
}
fieldType, err := g.goType(propSchema, hint)
if err != nil {
return "", fmt.Errorf("failed to generate field %s: %w", propName, err)
}
if isOmittable {
fieldType = fmt.Sprintf("mcp.Omittable[%s]", fieldType)
g.imports["go.probo.inc/mcpgen/mcp"] = true
} else if !isRequired && !isPointerType(fieldType) {
fieldType = "*" + fieldType
}
if propSchema.Description != "" {
buf.WriteString(formatComment(propSchema.Description, "\t"))
}
buf.WriteString(fmt.Sprintf("\t%s %s", fieldName, fieldType))
jsonTag := propName
if !isRequired {
jsonTag += ",omitempty"
}
buf.WriteString(fmt.Sprintf(" `json:\"%s\"`", jsonTag))
buf.WriteString("\n")
}
buf.WriteString("}")
return buf.String(), nil
}
// isPointerType checks if the given type string is already a pointer or slice type
func isPointerType(t string) bool {
return len(t) > 0 && (t[0] == '*' || t[0] == '[')
}
func isNullableType(s *schema.Schema) (bool, *schema.Schema) {
if len(s.AnyOf) == 2 {
var nullIndex = -1
var typeIndex = -1
for i, subSchema := range s.AnyOf {
subType := schema.GetType(subSchema)
if subType == "null" {
nullIndex = i
} else if subType != "" || subSchema.Properties != nil || subSchema.Ref != "" {
typeIndex = i
}
}
if nullIndex >= 0 && typeIndex >= 0 {
return true, s.AnyOf[typeIndex]
}
}
if len(s.Types) > 0 {
hasNull := false
var otherType string
for _, t := range s.Types {
if t == "null" {
hasNull = true
} else if otherType == "" {
otherType = t
}
}
if hasNull && otherType != "" && len(s.Types) == 2 {
syntheticSchema := &schema.Schema{
Type: otherType,
Format: s.Format,
}
return true, syntheticSchema
}
}
return false, nil
}
func (g *TypeGenerator) generateArrayType(name string, s *schema.Schema, depth int) (string, error) {
if s.Items == nil {
if depth == 0 {
return g.generatePrimitiveTypeAlias(name, s, "[]any")
}
return "[]any", nil
}
itemType, err := g.goType(s.Items, name+"Item")
if err != nil {
return "", err
}
arrayType := fmt.Sprintf("[]%s", itemType)
if depth == 0 {
return g.generatePrimitiveTypeAlias(name, s, arrayType)
}
return arrayType, nil
}
func (g *TypeGenerator) goType(s *schema.Schema, hint string) (string, error) {
if s.Ref != "" {
const prefix = "#/components/schemas/"
if len(s.Ref) > len(prefix) && s.Ref[:len(prefix)] == prefix {
schemaName := s.Ref[len(prefix):]
if customMapping, ok := g.customMappings[schemaName]; ok {
if customMapping.ImportPath != "" {
g.imports[customMapping.ImportPath] = true
}
if customMapping.IsPointer {
return "*" + customMapping.GoType, nil
}
return customMapping.GoType, nil
}
return "*" + toGoTypeName(schemaName), nil
}
}
if nullable, baseType := isNullableType(s); nullable {
goType, err := g.goType(baseType, hint)
if err != nil {
return "", err
}
if len(goType) > 0 && goType[0] == '*' {
return goType, nil
}
return "*" + goType, nil
}
schemaType := schema.GetType(s)
switch schemaType {
case "string":
if len(s.Enum) > 0 {
enumTypeName := toGoTypeName(hint)
if g.enums[enumTypeName] == "" {
enumCode, err := g.generateEnum(enumTypeName, s)
if err != nil {
return "", err
}
g.enums[enumTypeName] = enumCode
}
return enumTypeName, nil
}
return g.goStringType(s), nil
case "number":
return "float64", nil
case "integer":
return "int", nil
case "boolean":
return "bool", nil
case "array":
if s.Items == nil {
return "[]any", nil
}
itemType, err := g.goType(s.Items, hint+"Item")
if err != nil {
return "", err
}
return fmt.Sprintf("[]%s", itemType), nil
case "object":
if s.Title != "" {
typeName := toGoTypeName(s.Title)
if g.types[typeName] == "" {
typeCode, err := g.generateStruct(typeName, s, 0)
if err != nil {
return "", err
}
g.types[typeName] = typeCode
}
return typeName, nil
}
if len(s.Properties) > 0 {
typeName := hint
if g.types[typeName] == "" {
typeCode, err := g.generateStruct(typeName, s, 0)
if err != nil {
return "", err
}
g.types[typeName] = typeCode
}
return typeName, nil
}
return "map[string]any", nil
case "null":
return "any", nil
default:
if len(s.Properties) > 0 {
typeName := toGoTypeName(hint)
if g.types[typeName] == "" {
typeCode, err := g.generateStruct(typeName, s, 0)
if err != nil {
return "", err
}
g.types[typeName] = typeCode
}
return typeName, nil
}
return "any", nil
}
}
func (g *TypeGenerator) generatePrimitiveTypeAlias(name string, s *schema.Schema, goType string) (string, error) {
var buf strings.Builder
if s.Description != "" {
buf.WriteString(formatComment(s.Description, ""))
} else {
buf.WriteString(fmt.Sprintf("// %s represents a %s schema\n", name, goType))
}
buf.WriteString(fmt.Sprintf("type %s %s", name, goType))
return buf.String(), nil
}
func (g *TypeGenerator) generateEnum(enumTypeName string, s *schema.Schema) (string, error) {
if len(s.Enum) == 0 {
return "", fmt.Errorf("schema has no enum values")
}
var buf strings.Builder
if s.Description != "" {
buf.WriteString(formatComment(s.Description, ""))
} else {
buf.WriteString(fmt.Sprintf("// %s represents an enumeration\n", enumTypeName))
}
buf.WriteString(fmt.Sprintf("type %s string\n\n", enumTypeName))
buf.WriteString("const (\n")
var enumValues []string
for i, enumValue := range s.Enum {
strValue := fmt.Sprintf("%v", enumValue)
enumValues = append(enumValues, strValue)
constName := toEnumConstName(enumTypeName, strValue)
if i == 0 {
buf.WriteString(fmt.Sprintf("\t%s %s = %q\n", constName, enumTypeName, strValue))
} else {
buf.WriteString(fmt.Sprintf("\t%s %s = %q\n", constName, enumTypeName, strValue))
}
}
buf.WriteString(")\n\n")
// Generate validation method
buf.WriteString(fmt.Sprintf("// IsValid returns true if the %s value is valid\n", enumTypeName))
buf.WriteString(fmt.Sprintf("func (e %s) IsValid() bool {\n", enumTypeName))
buf.WriteString("\tswitch e {\n")
for _, strValue := range enumValues {
constName := toEnumConstName(enumTypeName, strValue)
buf.WriteString(fmt.Sprintf("\tcase %s:\n\t\treturn true\n", constName))
}
buf.WriteString("\t}\n")
buf.WriteString("\treturn false\n")
buf.WriteString("}\n\n")
// Generate UnmarshalJSON method
buf.WriteString("// UnmarshalJSON implements json.Unmarshaler\n")
buf.WriteString(fmt.Sprintf("func (e *%s) UnmarshalJSON(data []byte) error {\n", enumTypeName))
buf.WriteString("\tvar s string\n")
buf.WriteString("\tif err := json.Unmarshal(data, &s); err != nil {\n")
buf.WriteString("\t\treturn err\n")
buf.WriteString("\t}\n")
buf.WriteString(fmt.Sprintf("\t*e = %s(s)\n", enumTypeName))
buf.WriteString("\tif !e.IsValid() {\n")
buf.WriteString(fmt.Sprintf("\t\treturn fmt.Errorf(\"invalid %s value: %%q\", s)\n", enumTypeName))
buf.WriteString("\t}\n")
buf.WriteString("\treturn nil\n")
buf.WriteString("}\n\n")
// Generate MarshalJSON method
buf.WriteString("// MarshalJSON implements json.Marshaler\n")
buf.WriteString(fmt.Sprintf("func (e %s) MarshalJSON() ([]byte, error) {\n", enumTypeName))
buf.WriteString("\tif !e.IsValid() {\n")
buf.WriteString(fmt.Sprintf("\t\treturn nil, fmt.Errorf(\"invalid %s value: %%q\", string(e))\n", enumTypeName))
buf.WriteString("\t}\n")
buf.WriteString("\treturn json.Marshal(string(e))\n")
buf.WriteString("}")
g.imports["encoding/json"] = true
g.imports["fmt"] = true
return buf.String(), nil
}
func (g *TypeGenerator) goStringType(s *schema.Schema) string {
switch s.Format {
case "date-time":
g.imports["time"] = true
return "time.Time"
case "date", "time", "email", "hostname", "ipv4", "ipv6", "uri", "uuid":
return "string"
default:
return "string"
}
}
func toGoTypeName(name string) string {
name = strings.TrimSuffix(name, ".json")
name = strings.TrimSuffix(name, "_input")
name = strings.TrimSuffix(name, "_output")
name = strings.TrimSuffix(name, "_schema")
parts := strings.FieldsFunc(name, func(r rune) bool {
return r == '_' || r == '-' || r == ' ' || r == '.'
})
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, "")
}
var goAcronyms = map[string]bool{
"acl": true,
"api": true,
"ascii": true,
"cpu": true,
"css": true,
"dns": true,
"eof": true,
"guid": true,
"html": true,
"http": true,
"https": true,
"id": true,
"ip": true,
"json": true,
"jwt": true,
"lhs": true,
"qps": true,
"ram": true,
"rhs": true,
"rpc": true,
"sla": true,
"smtp": true,
"sql": true,
"ssh": true,
"tcp": true,
"tls": true,
"ttl": true,
"udp": true,
"ui": true,
"uid": true,
"uri": true,
"url": true,
"utf": true,
"uuid": true,
"vm": true,
"xml": true,
}
var goSpecialCase = map[string]string{
"oauth": "OAuth",
}
func toGoFieldName(name string) string {
parts := strings.FieldsFunc(name, func(r rune) bool {
return r == '_' || r == '-' || r == ' '
})
for i, part := range parts {
if len(part) > 0 {
lowerPart := strings.ToLower(part)
if specialCase, ok := goSpecialCase[lowerPart]; ok {
parts[i] = specialCase
} else if goAcronyms[lowerPart] {
parts[i] = strings.ToUpper(part)
} else {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
}
return strings.Join(parts, "")
}
func formatComment(text, prefix string) string {
lines := strings.Split(strings.TrimSpace(text), "\n")
var result strings.Builder
for _, line := range lines {
result.WriteString(fmt.Sprintf("%s// %s\n", prefix, strings.TrimSpace(line)))
}
return result.String()
}
func toEnumConstName(enumTypeName, value string) string {
parts := strings.FieldsFunc(value, func(r rune) bool {
return r == '_' || r == '-' || r == ' ' || r == '.'
})
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
constName := strings.Join(parts, "")
baseName := strings.TrimSuffix(enumTypeName, "Type")
return baseName + constName
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/google/jsonschema-go/jsonschema"
"gopkg.in/yaml.v3"
)
type Config struct {
Spec string `yaml:"spec" json:"spec"`
Output string `yaml:"output" json:"output"`
Exec ExecConfig `yaml:"exec,omitempty" json:"exec,omitempty"`
Resolver ResolverConfig `yaml:"resolver" json:"resolver"`
Model ModelConfig `yaml:"model,omitempty" json:"model,omitempty"`
Models ModelsConfig `yaml:"models,omitempty" json:"models,omitempty"`
}
type ExecConfig struct {
Package string `yaml:"package,omitempty" json:"package,omitempty"`
Filename string `yaml:"filename,omitempty" json:"filename,omitempty"`
}
type ResolverConfig struct {
Package string `yaml:"package" json:"package"`
Filename string `yaml:"filename" json:"filename"`
Type string `yaml:"type" json:"type"`
Preserve bool `yaml:"preserve" json:"preserve"`
}
type ModelConfig struct {
Package string `yaml:"package,omitempty" json:"package,omitempty"`
Filename string `yaml:"filename,omitempty" json:"filename,omitempty"`
}
type ModelsConfig struct {
// Map schema names to custom Go types
// Example: User: github.com/myorg/models.User
Models map[string]TypeMapping `yaml:",inline,omitempty" json:",inline,omitempty"`
}
type TypeMapping struct {
// Model is the fully qualified Go type to use
// Example: github.com/google/uuid.UUID
Model string `yaml:"model" json:"model"`
}
type ServerInfo struct {
Title string `yaml:"title" json:"title"`
Version string `yaml:"version" json:"version"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
}
type Components struct {
Schemas map[string]*jsonschema.Schema `yaml:"schemas,omitempty" json:"schemas,omitempty"`
}
type Schema = jsonschema.Schema
type ToolHints struct {
Readonly bool `yaml:"readonly,omitempty" json:"readonly,omitempty"`
Destructive bool `yaml:"destructive,omitempty" json:"destructive,omitempty"`
Idempotent bool `yaml:"idempotent,omitempty" json:"idempotent,omitempty"`
OpenWorld bool `yaml:"openWorld,omitempty" json:"openWorld,omitempty"`
}
type Tool struct {
Name string `yaml:"name" json:"name"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
InputSchema *Schema `yaml:"inputSchema" json:"inputSchema"`
OutputSchema *Schema `yaml:"outputSchema,omitempty" json:"outputSchema,omitempty"`
Hints *ToolHints `yaml:"hints,omitempty" json:"hints,omitempty"`
Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"`
Handler string `yaml:"handler,omitempty" json:"handler,omitempty"`
}
type Resource struct {
URI string `yaml:"uri,omitempty" json:"uri,omitempty"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
MimeType string `yaml:"mimeType,omitempty" json:"mimeType,omitempty"`
URITemplate string `yaml:"uriTemplate,omitempty" json:"uriTemplate,omitempty"`
Schema *Schema `yaml:"schema,omitempty" json:"schema,omitempty"`
Readonly bool `yaml:"readonly,omitempty" json:"readonly,omitempty"`
Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"`
Handler string `yaml:"handler,omitempty" json:"handler,omitempty"`
}
type Prompt struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Arguments []PromptArgument `yaml:"arguments,omitempty" json:"arguments,omitempty"`
Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"`
Handler string `yaml:"handler,omitempty" json:"handler,omitempty"`
}
type PromptArgument struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
}
func Load(path string) (*Config, *MCPSpec, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("failed to read config file: %w", err)
}
config := &Config{
Spec: "schema.yaml",
Output: "generated",
Exec: ExecConfig{
Package: "server",
Filename: "server/server.go",
},
Resolver: ResolverConfig{
Package: "generated",
Filename: "resolver.go",
Type: "Resolver",
Preserve: true,
},
Model: ModelConfig{
Package: "generated",
Filename: "models.go",
},
}
ext := filepath.Ext(path)
switch ext {
case ".yaml", ".yml":
if err := yaml.Unmarshal(data, config); err != nil {
return nil, nil, fmt.Errorf("failed to parse YAML config: %w", err)
}
case ".json":
if err := json.Unmarshal(data, config); err != nil {
return nil, nil, fmt.Errorf("failed to parse JSON config: %w", err)
}
default:
return nil, nil, fmt.Errorf("unsupported config file format: %s (use .yaml, .yml, or .json)", ext)
}
if err := config.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid configuration: %w", err)
}
// Make output path absolute relative to config file directory
configDir := filepath.Dir(path)
if !filepath.IsAbs(config.Output) {
config.Output = filepath.Join(configDir, config.Output)
}
specPath := config.Spec
if !filepath.IsAbs(specPath) {
configDir := filepath.Dir(path)
specPath = filepath.Join(configDir, specPath)
}
if _, err := os.Stat(specPath); os.IsNotExist(err) {
basePath := specPath
for _, ext := range []string{".yaml", ".yml", ".json"} {
tryPath := basePath
if filepath.Ext(tryPath) == "" {
tryPath = basePath + ext
} else {
tryPath = basePath[:len(basePath)-len(filepath.Ext(basePath))] + ext
}
if _, err := os.Stat(tryPath); err == nil {
specPath = tryPath
break
}
}
}
spec, err := LoadMCPSpec(specPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to load MCP spec from %s: %w", specPath, err)
}
return config, spec, nil
}
func (c *Config) Validate() error {
if c.Spec == "" {
return fmt.Errorf("spec path is required")
}
if c.Output == "" {
return fmt.Errorf("output is required")
}
if c.Exec.Package == "" {
return fmt.Errorf("exec.package is required")
}
if c.Resolver.Package == "" {
return fmt.Errorf("resolver.package is required")
}
if c.Model.Package == "" {
return fmt.Errorf("model.package is required")
}
return nil
}
func IsSchemaRef(s *Schema) bool {
return s != nil && s.Ref != ""
}

View File

@@ -0,0 +1,114 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type MCPSpec struct {
Info ServerInfo `yaml:"info" json:"info"`
Components Components `yaml:"components,omitempty" json:"components,omitempty"`
Tools []Tool `yaml:"tools,omitempty" json:"tools,omitempty"`
Resources []Resource `yaml:"resources,omitempty" json:"resources,omitempty"`
Prompts []Prompt `yaml:"prompts,omitempty" json:"prompts,omitempty"`
}
func LoadMCPSpec(path string) (*MCPSpec, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read MCP spec file: %w", err)
}
spec := &MCPSpec{}
ext := filepath.Ext(path)
switch ext {
case ".yaml", ".yml":
var intermediate interface{}
if err := yaml.Unmarshal(data, &intermediate); err != nil {
return nil, fmt.Errorf("failed to parse YAML spec: %w", err)
}
jsonData, err := json.Marshal(intermediate)
if err != nil {
return nil, fmt.Errorf("failed to convert YAML to JSON: %w", err)
}
if err := json.Unmarshal(jsonData, spec); err != nil {
return nil, fmt.Errorf("failed to unmarshal spec: %w", err)
}
case ".json":
if err := json.Unmarshal(data, spec); err != nil {
return nil, fmt.Errorf("failed to parse JSON spec: %w", err)
}
default:
return nil, fmt.Errorf("unsupported spec file format: %s (use .yaml, .yml, or .json)", ext)
}
if err := spec.Validate(); err != nil {
return nil, fmt.Errorf("invalid MCP specification: %w", err)
}
return spec, nil
}
func (s *MCPSpec) Validate() error {
if s.Info.Title == "" {
return fmt.Errorf("info.title is required")
}
if s.Info.Version == "" {
return fmt.Errorf("info.version is required")
}
for i, tool := range s.Tools {
if tool.Name == "" {
return fmt.Errorf("tools[%d].name is required", i)
}
if tool.InputSchema == nil {
return fmt.Errorf("tools[%d].inputSchema is required", i)
}
}
for i, resource := range s.Resources {
if resource.Name == "" {
return fmt.Errorf("resources[%d].name is required", i)
}
if resource.URI == "" && resource.URITemplate == "" {
return fmt.Errorf("resources[%d] must have either uri or uriTemplate", i)
}
if resource.URI != "" && resource.URITemplate != "" {
return fmt.Errorf("resources[%d] cannot have both uri and uriTemplate", i)
}
}
for i, prompt := range s.Prompts {
if prompt.Name == "" {
return fmt.Errorf("prompts[%d].name is required", i)
}
}
return nil
}
func (s *MCPSpec) ResolveSchemaRef(ref string) (*Schema, error) {
if len(ref) > 0 && ref[0] == '#' {
if ref == "#/components/schemas" {
return nil, fmt.Errorf("incomplete schema reference: %s", ref)
}
const prefix = "#/components/schemas/"
if len(ref) > len(prefix) && ref[:len(prefix)] == prefix {
schemaName := ref[len(prefix):]
if schema, ok := s.Components.Schemas[schemaName]; ok {
return schema, nil
}
return nil, fmt.Errorf("schema not found: %s", schemaName)
}
return nil, fmt.Errorf("unsupported reference format: %s", ref)
}
return nil, nil
}

View File

@@ -0,0 +1,85 @@
package schema
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/google/jsonschema-go/jsonschema"
)
type Schema = jsonschema.Schema
type Loader struct {
schemas map[string]*Schema
baseDir string
}
func NewLoader(baseDir string) *Loader {
return &Loader{
schemas: make(map[string]*Schema),
baseDir: baseDir,
}
}
func (l *Loader) Load(path string) (*Schema, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path: %w", err)
}
if schema, ok := l.schemas[absPath]; ok {
return schema, nil
}
data, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read schema file %s: %w", path, err)
}
var schema Schema
if err := json.Unmarshal(data, &schema); err != nil {
return nil, fmt.Errorf("failed to parse schema file %s: %w", path, err)
}
l.schemas[absPath] = &schema
return &schema, nil
}
func GetType(s *Schema) string {
if s.Type != "" {
return s.Type
}
if len(s.Types) > 0 {
return s.Types[0]
}
return ""
}
func IsRequired(s *Schema, propName string) bool {
for _, req := range s.Required {
if req == propName {
return true
}
}
return false
}
// IsOmittable checks if a schema property has the go.probo.inc/mcpgen/omittable annotation set to true.
// This is used to wrap fields in mcp.Omittable[T] to distinguish between
// "not set", "set to null", and "set to value".
func IsOmittable(s *Schema) bool {
if s == nil || s.Extra == nil {
return false
}
if omittable, ok := s.Extra["go.probo.inc/mcpgen/omittable"]; ok {
if omittableBool, ok := omittable.(bool); ok {
return omittableBool
}
}
return false
}

185
third_party/mcpgen/main.go vendored Normal file
View File

@@ -0,0 +1,185 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"go.probo.inc/mcpgen/internal/codegen"
"go.probo.inc/mcpgen/internal/config"
)
var version = "dev"
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var rootCmd = &cobra.Command{
Use: "mcpgen",
Short: "A code generator for Model Context Protocol (MCP) servers",
Long: `mcpgen is a gqlgen-like code generator for building MCP servers in Go.
It generates type-safe Go code from JSON Schema definitions for tools, resources, and prompts.`,
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version number of mcpgen",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("mcpgen %s\n", version)
},
}
var generateCmd = &cobra.Command{
Use: "generate",
Short: "Generate Go code from mcpgen configuration",
Long: `Reads mcpgen.yaml (or mcpgen.yml) configuration file and generates:
- Type-safe Go structs from JSON Schemas
- MCP server boilerplate code
- Handler function stubs for tools, resources, and prompts`,
RunE: func(cmd *cobra.Command, args []string) error {
configFile, _ := cmd.Flags().GetString("config")
return runGenerate(configFile)
},
}
var initCmd = &cobra.Command{
Use: "init [name]",
Short: "Initialize a new MCP server project",
Long: `Creates a new MCP server project with example configuration and file structure.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := "my-mcp-server"
if len(args) > 0 {
name = args[0]
}
return runInit(name)
},
}
func init() {
generateCmd.Flags().StringP("config", "c", "mcpgen.yaml", "Path to config file")
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(generateCmd)
rootCmd.AddCommand(initCmd)
}
func runGenerate(configFile string) error {
if _, err := os.Stat(configFile); os.IsNotExist(err) {
if configFile == "mcpgen.yaml" {
if _, err := os.Stat("mcpgen.yml"); err == nil {
configFile = "mcpgen.yml"
}
}
}
fmt.Printf("Loading configuration from %s...\n", configFile)
cfg, spec, err := config.Load(configFile)
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}
fmt.Printf("Generating code for %s v%s...\n", spec.Info.Title, spec.Info.Version)
gen := codegen.New(cfg, spec)
if err := gen.Generate(); err != nil {
return fmt.Errorf("code generation failed: %w", err)
}
fmt.Println("✓ Code generation completed successfully!")
return nil
}
func runInit(name string) error {
fmt.Printf("Initializing new MCP server project: %s\n", name)
if err := os.MkdirAll(name, 0755); err != nil {
return fmt.Errorf("failed to create project directory: %w", err)
}
configContent := `# mcpgen configuration
# Path to MCP API specification
spec: schema.yaml
# Output directory for generated code
output: generated
# Resolver configuration
resolver:
package: generated
filename: resolver.go
type: Resolver
preserve: true
# Model configuration
model:
package: generated
filename: models.go
`
configPath := filepath.Join(name, "mcpgen.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
schemaContent := fmt.Sprintf(`# MCP API Specification
# This file contains the pure MCP API definition
info:
title: %s
version: 1.0.0
description: An example MCP server
# Reusable schema components
components:
schemas:
ExampleInput:
type: object
properties:
message:
type: string
description: The message to process
required: [message]
# MCP Tools
tools:
- name: example_tool
title: Example Tool
description: An example tool that processes messages
hints:
readonly: false
destructive: false
idempotent: true
inputSchema:
$ref: "#/components/schemas/ExampleInput"
# MCP Resources
resources: []
# MCP Prompts
prompts: []
`, name)
schemaPath := filepath.Join(name, "schema.yaml")
if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil {
return fmt.Errorf("failed to write schema file: %w", err)
}
fmt.Printf("\n✓ Project initialized successfully!\n\n")
fmt.Printf("Files created:\n")
fmt.Printf(" - mcpgen.yaml (code generation configuration)\n")
fmt.Printf(" - schema.yaml (MCP API specification)\n\n")
fmt.Printf("Next steps:\n")
fmt.Printf(" cd %s\n", name)
fmt.Printf(" # Edit schema.yaml to define your tools, resources, and prompts\n")
fmt.Printf(" mcpgen generate\n")
return nil
}

117
third_party/mcpgen/mcp/omittable.go vendored Normal file
View File

@@ -0,0 +1,117 @@
package mcp
import (
"encoding/json"
"fmt"
)
// Omittable represents a value that can be in one of three states:
// 1. Not set (field was not provided in JSON)
// 2. Explicitly set to null
// 3. Set to a value
//
// This is useful for distinguishing between "don't update this field" (not set)
// and "set this field to null" (explicitly null) in update operations.
//
// Example usage:
//
// type UpdateUserInput struct {
// Name Omittable[string] `json:"name,omitempty"`
// Email Omittable[string] `json:"email,omitempty"`
// }
//
// func (r *Resolver) UpdateUser(input UpdateUserInput) {
// if input.Name.IsSet() {
// if input.Name.IsNull() {
// // Set name to null
// } else {
// // Update name to input.Name.Value()
// }
// }
// // If !IsSet(), don't touch the name field
// }
type Omittable[T any] struct {
value *T
isSet bool
}
func NewOmittable[T any](value T) Omittable[T] {
return Omittable[T]{
value: &value,
isSet: true,
}
}
func NewOmittableNull[T any]() Omittable[T] {
return Omittable[T]{
value: nil,
isSet: true,
}
}
// IsSet returns true if the field was provided in the input (either null or a value).
func (o Omittable[T]) IsSet() bool {
return o.isSet
}
// IsNull returns true if the field was explicitly set to null.
// Returns false if the field was not set or has a value.
func (o Omittable[T]) IsNull() bool {
return o.isSet && o.value == nil
}
// Value returns the value and a boolean indicating if it has a non-null value.
// If the field is not set or is null, returns the zero value and false.
func (o Omittable[T]) Value() (T, bool) {
if o.value != nil {
return *o.value, true
}
var zero T
return zero, false
}
func (o Omittable[T]) ValueOrZero() T {
if o.value != nil {
return *o.value
}
var zero T
return zero
}
func (o Omittable[T]) Ptr() *T {
return o.value
}
// UnmarshalJSON implements json.Unmarshaler.
func (o *Omittable[T]) UnmarshalJSON(data []byte) error {
o.isSet = true
// Handle explicit null
if string(data) == "null" {
o.value = nil
return nil
}
// Unmarshal the actual value
var value T
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf("failed to unmarshal omittable value: %w", err)
}
o.value = &value
return nil
}
// MarshalJSON implements json.Marshaler.
func (o Omittable[T]) MarshalJSON() ([]byte, error) {
// Note: When marshaling structs with Omittable fields, use *Omittable[T]
// if you need omitempty to work correctly. With value types, omitempty
// doesn't work well with custom MarshalJSON.
// For MCP use cases (unmarshaling input), this is not typically an issue.
if !o.isSet || o.value == nil {
return []byte("null"), nil
}
return json.Marshal(*o.value)
}

201
third_party/mcpgen/mcp/omittable_test.go vendored Normal file
View File

@@ -0,0 +1,201 @@
package mcp
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOmittable_NotSet(t *testing.T) {
var o Omittable[string]
assert.False(t, o.IsSet())
assert.False(t, o.IsNull())
value, ok := o.Value()
assert.False(t, ok)
assert.Equal(t, "", value)
}
func TestOmittable_SetToValue(t *testing.T) {
o := NewOmittable("hello")
assert.True(t, o.IsSet())
assert.False(t, o.IsNull())
value, ok := o.Value()
assert.True(t, ok)
assert.Equal(t, "hello", value)
assert.Equal(t, "hello", o.ValueOrZero())
ptr := o.Ptr()
require.NotNil(t, ptr)
assert.Equal(t, "hello", *ptr)
}
func TestOmittable_SetToNull(t *testing.T) {
o := NewOmittableNull[string]()
assert.True(t, o.IsSet())
assert.True(t, o.IsNull())
value, ok := o.Value()
assert.False(t, ok)
assert.Equal(t, "", value)
assert.Equal(t, "", o.ValueOrZero())
assert.Nil(t, o.Ptr())
}
func TestOmittable_UnmarshalJSON_NotProvided(t *testing.T) {
type Input struct {
Name Omittable[string] `json:"name,omitempty"`
Email Omittable[string] `json:"email,omitempty"`
}
jsonData := `{"name": "John"}`
var input Input
require.NoError(t, json.Unmarshal([]byte(jsonData), &input))
assert.True(t, input.Name.IsSet())
name, ok := input.Name.Value()
assert.True(t, ok)
assert.Equal(t, "John", name)
assert.False(t, input.Email.IsSet())
}
func TestOmittable_UnmarshalJSON_ExplicitNull(t *testing.T) {
type Input struct {
Name Omittable[string] `json:"name,omitempty"`
Email Omittable[string] `json:"email,omitempty"`
}
jsonData := `{"name": null, "email": "test@example.com"}`
var input Input
require.NoError(t, json.Unmarshal([]byte(jsonData), &input))
assert.True(t, input.Name.IsSet())
assert.True(t, input.Name.IsNull())
assert.True(t, input.Email.IsSet())
email, ok := input.Email.Value()
assert.True(t, ok)
assert.Equal(t, "test@example.com", email)
}
func TestOmittable_UnmarshalJSON_WithValue(t *testing.T) {
type Input struct {
Count Omittable[int] `json:"count,omitempty"`
}
jsonData := `{"count": 42}`
var input Input
require.NoError(t, json.Unmarshal([]byte(jsonData), &input))
assert.True(t, input.Count.IsSet())
assert.False(t, input.Count.IsNull())
count, ok := input.Count.Value()
assert.True(t, ok)
assert.Equal(t, 42, count)
}
func TestOmittable_MarshalJSON_NotSet(t *testing.T) {
type Output struct {
Name Omittable[string] `json:"name,omitempty"`
}
output := Output{}
data, err := json.Marshal(output)
require.NoError(t, err)
assert.JSONEq(t, `{"name":null}`, string(data))
}
func TestOmittable_MarshalJSON_Null(t *testing.T) {
type Output struct {
Name Omittable[string] `json:"name,omitempty"`
}
output := Output{
Name: NewOmittableNull[string](),
}
data, err := json.Marshal(output)
require.NoError(t, err)
assert.JSONEq(t, `{"name":null}`, string(data))
}
func TestOmittable_MarshalJSON_WithValue(t *testing.T) {
type Output struct {
Name Omittable[string] `json:"name,omitempty"`
}
output := Output{
Name: NewOmittable("Alice"),
}
data, err := json.Marshal(output)
require.NoError(t, err)
assert.JSONEq(t, `{"name":"Alice"}`, string(data))
}
func TestOmittable_ComplexTypes(t *testing.T) {
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
type Input struct {
Person Omittable[Person] `json:"person,omitempty"`
}
t.Run("with value", func(t *testing.T) {
jsonData := `{"person": {"name": "John", "age": 30}}`
var input Input
require.NoError(t, json.Unmarshal([]byte(jsonData), &input))
assert.True(t, input.Person.IsSet())
person, ok := input.Person.Value()
assert.True(t, ok)
assert.Equal(t, "John", person.Name)
assert.Equal(t, 30, person.Age)
})
t.Run("with null", func(t *testing.T) {
jsonData := `{"person": null}`
var input Input
require.NoError(t, json.Unmarshal([]byte(jsonData), &input))
assert.True(t, input.Person.IsSet())
assert.True(t, input.Person.IsNull())
})
}
func TestOmittable_Pointers(t *testing.T) {
type Input struct {
Name Omittable[*string] `json:"name,omitempty"`
}
t.Run("with value", func(t *testing.T) {
jsonData := `{"name": "hello"}`
var input Input
require.NoError(t, json.Unmarshal([]byte(jsonData), &input))
assert.True(t, input.Name.IsSet())
value, ok := input.Name.Value()
assert.True(t, ok)
require.NotNil(t, value)
assert.Equal(t, "hello", *value)
})
t.Run("with null", func(t *testing.T) {
jsonData := `{"name": null}`
var input Input
require.NoError(t, json.Unmarshal([]byte(jsonData), &input))
assert.True(t, input.Name.IsSet())
assert.True(t, input.Name.IsNull())
})
}

63
third_party/mcpgen/mcp/recover.go vendored Normal file
View File

@@ -0,0 +1,63 @@
package mcp
import (
"context"
"errors"
"fmt"
"os"
"runtime/debug"
)
// RecoverFunc is called when a tool handler panics. It receives the recovered
// value (whatever was passed to panic) and returns an error to be reported to
// the client.
//
// This matches the signature and semantics of gqlgen's RecoverFunc.
//
// Example:
//
// server.New(resolver, server.WithRecoverFunc(func(ctx context.Context, err any) error {
// log.Error("tool panic", "err", err)
// return errors.New("internal server error")
// }))
type RecoverFunc func(ctx context.Context, err any) error
// DefaultRecoverFunc prints the panic and stack trace to stderr and returns a
// generic internal error. This matches gqlgen's DefaultRecover behavior.
func DefaultRecoverFunc(_ context.Context, err any) error {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(os.Stderr)
debug.PrintStack()
return errors.New("internal system error")
}
// Option configures the generated MCP server.
type Option func(*Options)
// Options holds configuration for the generated MCP server.
type Options struct {
RecoverFunc RecoverFunc
}
// WithRecoverFunc sets the panic recover function for tool handlers.
// The recover function is called when a tool handler panics, and its return
// value is sent to the client in place of the panic.
func WithRecoverFunc(fn RecoverFunc) Option {
return func(o *Options) {
o.RecoverFunc = fn
}
}
// ApplyOptions applies the given options to an Options struct.
// If RecoverFunc is nil after applying options, it is set to DefaultRecoverFunc:
// recovery is always enabled, matching gqlgen's behavior.
func ApplyOptions(opts []Option) Options {
var o Options
for _, opt := range opts {
opt(&o)
}
if o.RecoverFunc == nil {
o.RecoverFunc = DefaultRecoverFunc
}
return o
}

51
third_party/mcpgen/mcp/recover_test.go vendored Normal file
View File

@@ -0,0 +1,51 @@
package mcp
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestApplyOptions(t *testing.T) {
t.Run("no options uses default recover func", func(t *testing.T) {
opts := ApplyOptions(nil)
assert.NotNil(t, opts.RecoverFunc)
})
t.Run("nil recover func falls back to default", func(t *testing.T) {
opts := ApplyOptions([]Option{WithRecoverFunc(nil)})
assert.NotNil(t, opts.RecoverFunc)
})
t.Run("with custom recover func", func(t *testing.T) {
fn := func(_ context.Context, _ any) error {
return errors.New("sanitized")
}
opts := ApplyOptions([]Option{WithRecoverFunc(fn)})
assert.NotNil(t, opts.RecoverFunc)
err := opts.RecoverFunc(context.Background(), "boom")
assert.Equal(t, "sanitized", err.Error())
})
t.Run("recover func receives raw panic value", func(t *testing.T) {
var captured any
fn := func(_ context.Context, err any) error {
captured = err
return nil
}
opts := ApplyOptions([]Option{WithRecoverFunc(fn)})
opts.RecoverFunc(context.Background(), 42)
assert.Equal(t, 42, captured)
opts.RecoverFunc(context.Background(), "string panic")
assert.Equal(t, "string panic", captured)
original := errors.New("error panic")
opts.RecoverFunc(context.Background(), original)
assert.Equal(t, original, captured)
})
}

77
third_party/mcpgen/mcp/schema.go vendored Normal file
View File

@@ -0,0 +1,77 @@
package mcp
import (
"context"
"encoding/json"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MustUnmarshalSchema unmarshals a JSON schema string into a jsonschema.Schema
// Panics if unmarshaling fails, providing compile-time safety for schema definitions
func MustUnmarshalSchema(schemaJSON string) *jsonschema.Schema {
var schema jsonschema.Schema
if err := json.Unmarshal([]byte(schemaJSON), &schema); err != nil {
panic("invalid schema JSON: " + err.Error())
}
return &schema
}
// PromptHandlerFor is a typed prompt handler that accepts structured arguments.
// Similar to mcp.ToolHandlerFor, this allows prompts to work with typed Go structs
// instead of raw map[string]string.
//
// The Args type parameter must be a struct or map type. Arguments will be automatically
// unmarshaled from the prompt request's Arguments map into the Args type.
//
// Example:
//
// type TaskArgs struct {
// Topic string `json:"topic"`
// Detailed bool `json:"detailed"`
// }
//
// func (r *Resolver) TaskHelpPrompt(ctx context.Context, req *mcp.GetPromptRequest, args TaskArgs) (*mcp.GetPromptResult, error) {
// // args.Topic and args.Detailed are already parsed
// return &mcp.GetPromptResult{...}, nil
// }
type PromptHandlerFor[Args any] func(context.Context, *mcp.GetPromptRequest, Args) (*mcp.GetPromptResult, error)
// AddPrompt is a generic wrapper around Server.AddPrompt that provides type-safe argument handling.
// It automatically converts the prompt arguments from map[string]string into the typed Args parameter.
//
// This matches the ergonomics of mcp.AddTool for a consistent API experience across tools and prompts.
//
// The Args type must be a struct with string fields or map[string]string. If it's a struct, the fields
// will be populated from the arguments map based on their json tags.
//
// Example:
//
// type HelpArgs struct {
// Topic string `json:"topic"`
// }
//
// mcp.AddPrompt(server, &mcp.Prompt{
// Name: "help",
// Description: "Get help",
// }, resolver.HelpPrompt) // HelpPrompt receives typed HelpArgs
func AddPrompt[Args any](s *mcp.Server, p *mcp.Prompt, h PromptHandlerFor[Args]) {
s.AddPrompt(p, func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
var args Args
// Convert map[string]string to typed Args using JSON as the intermediary.
// This properly handles json tags and field mapping.
if len(req.Params.Arguments) > 0 {
argsBytes, err := json.Marshal(req.Params.Arguments)
if err != nil {
return nil, err
}
if err := json.Unmarshal(argsBytes, &args); err != nil {
return nil, err
}
}
return h(ctx, req, args)
})
}