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:
@@ -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
2
go.mod
@@ -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
2
go.sum
@@ -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
20
third_party/mcpgen/LICENSE
vendored
Normal 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
381
third_party/mcpgen/README.md
vendored
Normal 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
12
third_party/mcpgen/THIRD_PARTY.md
vendored
Normal 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
21
third_party/mcpgen/go.mod
vendored
Normal 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
32
third_party/mcpgen/go.sum
vendored
Normal 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=
|
||||
1292
third_party/mcpgen/internal/codegen/generator.go
vendored
Normal file
1292
third_party/mcpgen/internal/codegen/generator.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1387
third_party/mcpgen/internal/codegen/generator_test.go
vendored
Normal file
1387
third_party/mcpgen/internal/codegen/generator_test.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
282
third_party/mcpgen/internal/codegen/integration_test.go
vendored
Normal file
282
third_party/mcpgen/internal/codegen/integration_test.go
vendored
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
191
third_party/mcpgen/internal/codegen/parser.go
vendored
Normal file
191
third_party/mcpgen/internal/codegen/parser.go
vendored
Normal 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()
|
||||
}
|
||||
484
third_party/mcpgen/internal/codegen/parser_test.go
vendored
Normal file
484
third_party/mcpgen/internal/codegen/parser_test.go
vendored
Normal 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")
|
||||
}
|
||||
58
third_party/mcpgen/internal/codegen/templates/resolver.gotpl
vendored
Normal file
58
third_party/mcpgen/internal/codegen/templates/resolver.gotpl
vendored
Normal 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}}
|
||||
22
third_party/mcpgen/internal/codegen/templates/resolver_struct.gotpl
vendored
Normal file
22
third_party/mcpgen/internal/codegen/templates/resolver_struct.gotpl
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
175
third_party/mcpgen/internal/codegen/templates/server.gotpl
vendored
Normal file
175
third_party/mcpgen/internal/codegen/templates/server.gotpl
vendored
Normal 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}}
|
||||
222
third_party/mcpgen/internal/codegen/testdata/all_primitives.yaml
vendored
Normal file
222
third_party/mcpgen/internal/codegen/testdata/all_primitives.yaml
vendored
Normal 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
|
||||
31
third_party/mcpgen/internal/codegen/testdata/config_based_types.golden
vendored
Normal file
31
third_party/mcpgen/internal/codegen/testdata/config_based_types.golden
vendored
Normal 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"`
|
||||
}
|
||||
45
third_party/mcpgen/internal/codegen/testdata/config_based_types.yaml
vendored
Normal file
45
third_party/mcpgen/internal/codegen/testdata/config_based_types.yaml
vendored
Normal 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"
|
||||
185
third_party/mcpgen/internal/codegen/testdata/custom_types.yaml
vendored
Normal file
185
third_party/mcpgen/internal/codegen/testdata/custom_types.yaml
vendored
Normal 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
|
||||
652
third_party/mcpgen/internal/codegen/types.go
vendored
Normal file
652
third_party/mcpgen/internal/codegen/types.go
vendored
Normal 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
|
||||
}
|
||||
1048
third_party/mcpgen/internal/codegen/types_test.go
vendored
Normal file
1048
third_party/mcpgen/internal/codegen/types_test.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
208
third_party/mcpgen/internal/config/config.go
vendored
Normal file
208
third_party/mcpgen/internal/config/config.go
vendored
Normal 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 != ""
|
||||
}
|
||||
114
third_party/mcpgen/internal/config/spec.go
vendored
Normal file
114
third_party/mcpgen/internal/config/spec.go
vendored
Normal 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
|
||||
}
|
||||
85
third_party/mcpgen/internal/schema/schema.go
vendored
Normal file
85
third_party/mcpgen/internal/schema/schema.go
vendored
Normal 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
185
third_party/mcpgen/main.go
vendored
Normal 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
117
third_party/mcpgen/mcp/omittable.go
vendored
Normal 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
201
third_party/mcpgen/mcp/omittable_test.go
vendored
Normal 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
63
third_party/mcpgen/mcp/recover.go
vendored
Normal 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
51
third_party/mcpgen/mcp/recover_test.go
vendored
Normal 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
77
third_party/mcpgen/mcp/schema.go
vendored
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user