Skip to content

Commit b6dd750

Browse files
committed
add base url override option
allows dynamic discovery of base url
1 parent ae698be commit b6dd750

11 files changed

Lines changed: 1920 additions & 125 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,21 @@ testdatamcp.ForwardToConnectTestServiceClient(mcpServer, myConnectClient)
114114

115115
This directly connects the MCP handler to the connectrpc client, requiring zero boilerplate.
116116

117+
### URL Override Support
118+
119+
All generated MCP functions support dynamic URL overrides via functional options. This allows LLMs to specify custom base URLs for different service environments:
120+
121+
```go
122+
// Enable URL override with custom field name and description
123+
option := runtime.WithBaseURLProperty("base_url", "Base URL for the API")
124+
125+
// Use with any generated function
126+
testdatamcp.RegisterTestServiceHandler(mcpServer, &srv, option)
127+
testdatamcp.ForwardToTestServiceClient(mcpServer, client, option)
128+
```
129+
130+
When enabled, the tool schema automatically includes the URL field, and LLMs can provide URLs like `https://api.example.com` which get parsed and made available in the request context via `runtime.URLOverrideKey{}`.
131+
117132
## LLM Provider Compatibility
118133

119134
The generator now creates both standard MCP and OpenAI-compatible handlers automatically. You can choose which to use at runtime:
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2025 Redpanda Data, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package generator
16+
17+
import (
18+
"encoding/json"
19+
"testing"
20+
21+
. "github.com/onsi/gomega"
22+
"github.com/redpanda-data/protoc-gen-go-mcp/pkg/runtime"
23+
testdatamcp "github.com/redpanda-data/protoc-gen-go-mcp/pkg/testdata/gen/go/testdata/testdatamcp"
24+
)
25+
26+
func TestURLOverrideSchemaModification(t *testing.T) {
27+
g := NewWithT(t)
28+
29+
// Test that the AddURLFieldToTool function properly modifies the CreateItem tool schema
30+
originalTool := testdatamcp.TestService_CreateItemTool
31+
32+
// Parse the original schema to verify it doesn't have base_url field
33+
var originalSchema map[string]interface{}
34+
err := json.Unmarshal(originalTool.RawInputSchema, &originalSchema)
35+
g.Expect(err).ToNot(HaveOccurred())
36+
37+
originalProperties := originalSchema["properties"].(map[string]interface{})
38+
g.Expect(originalProperties).ToNot(HaveKey("base_url"))
39+
40+
// Add base_url field to the tool
41+
modifiedTool := runtime.AddURLFieldToTool(originalTool, "base_url", "Base URL for the API")
42+
43+
// Parse the modified schema
44+
var modifiedSchema map[string]interface{}
45+
err = json.Unmarshal(modifiedTool.RawInputSchema, &modifiedSchema)
46+
g.Expect(err).ToNot(HaveOccurred())
47+
48+
// Verify the base_url field was added
49+
modifiedProperties := modifiedSchema["properties"].(map[string]interface{})
50+
g.Expect(modifiedProperties).To(HaveKey("base_url"))
51+
52+
urlField := modifiedProperties["base_url"].(map[string]interface{})
53+
g.Expect(urlField["type"]).To(Equal("string"))
54+
g.Expect(urlField["format"]).To(Equal("uri"))
55+
g.Expect(urlField["description"]).To(Equal("Base URL for the API"))
56+
57+
// Verify original fields are still there
58+
g.Expect(modifiedProperties).To(HaveKey("name"))
59+
g.Expect(modifiedProperties).To(HaveKey("description"))
60+
g.Expect(modifiedProperties).To(HaveKey("labels"))
61+
g.Expect(modifiedProperties).To(HaveKey("tags"))
62+
}
63+
64+
func TestURLOverrideSchemaModificationWithCustomField(t *testing.T) {
65+
g := NewWithT(t)
66+
67+
// Test that the AddURLFieldToTool function properly modifies the CreateItem tool schema with custom field name
68+
originalTool := testdatamcp.TestService_CreateItemTool
69+
70+
// Parse the original schema to verify it doesn't have custom field
71+
var originalSchema map[string]interface{}
72+
err := json.Unmarshal(originalTool.RawInputSchema, &originalSchema)
73+
g.Expect(err).ToNot(HaveOccurred())
74+
75+
originalProperties := originalSchema["properties"].(map[string]interface{})
76+
g.Expect(originalProperties).ToNot(HaveKey("api_url"))
77+
78+
// Add custom field to the tool
79+
modifiedTool := runtime.AddURLFieldToTool(originalTool, "api_url", "Custom API endpoint URL")
80+
81+
// Parse the modified schema
82+
var modifiedSchema map[string]interface{}
83+
err = json.Unmarshal(modifiedTool.RawInputSchema, &modifiedSchema)
84+
g.Expect(err).ToNot(HaveOccurred())
85+
86+
// Verify the custom field was added
87+
modifiedProperties := modifiedSchema["properties"].(map[string]interface{})
88+
g.Expect(modifiedProperties).To(HaveKey("api_url"))
89+
g.Expect(modifiedProperties).ToNot(HaveKey("base_url")) // Should not have default base_url field
90+
91+
customField := modifiedProperties["api_url"].(map[string]interface{})
92+
g.Expect(customField["type"]).To(Equal("string"))
93+
g.Expect(customField["format"]).To(Equal("uri"))
94+
g.Expect(customField["description"]).To(Equal("Custom API endpoint URL"))
95+
96+
// Verify original fields are still there
97+
g.Expect(modifiedProperties).To(HaveKey("name"))
98+
g.Expect(modifiedProperties).To(HaveKey("description"))
99+
g.Expect(modifiedProperties).To(HaveKey("labels"))
100+
g.Expect(modifiedProperties).To(HaveKey("tags"))
101+
}

pkg/generator/generator.go

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import (
6565
"connectrpc.com/connect"
6666
grpc "google.golang.org/grpc"
6767
"github.com/redpanda-data/protoc-gen-go-mcp/pkg/runtime"
68+
"net/url"
6869
)
6970
7071
@@ -88,13 +89,35 @@ type {{$serviceName}}Server interface {
8889
8990
{{- range $key, $val := .Services }}
9091
// Register{{$key}}Handler registers standard MCP handlers for {{$key}}
91-
func Register{{$key}}Handler(s *mcpserver.MCPServer, srv {{$key}}Server) {
92+
func Register{{$key}}Handler(s *mcpserver.MCPServer, srv {{$key}}Server, opts ...runtime.Option) {
93+
config := runtime.NewConfig()
94+
for _, opt := range opts {
95+
opt(config)
96+
}
97+
9298
{{- range $tool_name, $tool_val := $val }}
93-
s.AddTool({{$key}}_{{$tool_name}}Tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
99+
{{$tool_name}}Tool := {{$key}}_{{$tool_name}}Tool
100+
// Add URL field to schema if ExtractURL is enabled
101+
if config.ExtractURL {
102+
{{$tool_name}}Tool = runtime.AddURLFieldToTool({{$tool_name}}Tool, config.URLFieldName, config.URLDescription)
103+
}
104+
105+
s.AddTool({{$tool_name}}Tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
94106
var req {{$tool_val.RequestType}}
95107
96108
message := request.Params.Arguments
97109
110+
// Extract URL if option is enabled
111+
if config.ExtractURL {
112+
if urlVal, ok := message[config.URLFieldName]; ok {
113+
if urlStr, ok := urlVal.(string); ok {
114+
if parsedURL, err := url.Parse(urlStr); err == nil {
115+
ctx = context.WithValue(ctx, runtime.URLOverrideKey{}, parsedURL)
116+
}
117+
}
118+
}
119+
}
120+
98121
marshaled, err := json.Marshal(message)
99122
if err != nil {
100123
return nil, err
@@ -120,12 +143,35 @@ func Register{{$key}}Handler(s *mcpserver.MCPServer, srv {{$key}}Server) {
120143
}
121144
122145
// Register{{$key}}HandlerOpenAI registers OpenAI-compatible MCP handlers for {{$key}}
123-
func Register{{$key}}HandlerOpenAI(s *mcpserver.MCPServer, srv {{$key}}Server) {
146+
func Register{{$key}}HandlerOpenAI(s *mcpserver.MCPServer, srv {{$key}}Server, opts ...runtime.Option) {
147+
config := runtime.NewConfig()
148+
for _, opt := range opts {
149+
opt(config)
150+
}
151+
124152
{{- range $tool_name, $tool_val := $val }}
125-
s.AddTool({{$key}}_{{$tool_name}}ToolOpenAI, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
153+
{{$tool_name}}ToolOpenAI := {{$key}}_{{$tool_name}}ToolOpenAI
154+
// Add URL field to schema if ExtractURL is enabled
155+
if config.ExtractURL {
156+
{{$tool_name}}ToolOpenAI = runtime.AddURLFieldToTool({{$tool_name}}ToolOpenAI, config.URLFieldName, config.URLDescription)
157+
}
158+
159+
s.AddTool({{$tool_name}}ToolOpenAI, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
126160
var req {{$tool_val.RequestType}}
127161
128162
message := request.Params.Arguments
163+
164+
// Extract URL if option is enabled
165+
if config.ExtractURL {
166+
if urlVal, ok := message[config.URLFieldName]; ok {
167+
if urlStr, ok := urlVal.(string); ok {
168+
if parsedURL, err := url.Parse(urlStr); err == nil {
169+
ctx = context.WithValue(ctx, runtime.URLOverrideKey{}, parsedURL)
170+
}
171+
}
172+
}
173+
}
174+
129175
runtime.FixOpenAI(req.ProtoReflect().Descriptor(), message)
130176
131177
marshaled, err := json.Marshal(message)
@@ -153,14 +199,14 @@ func Register{{$key}}HandlerOpenAI(s *mcpserver.MCPServer, srv {{$key}}Server) {
153199
}
154200
155201
// Register{{$key}}HandlerWithProvider registers handlers for the specified LLM provider
156-
func Register{{$key}}HandlerWithProvider(s *mcpserver.MCPServer, srv {{$key}}Server, provider runtime.LLMProvider) {
202+
func Register{{$key}}HandlerWithProvider(s *mcpserver.MCPServer, srv {{$key}}Server, provider runtime.LLMProvider, opts ...runtime.Option) {
157203
switch provider {
158204
case runtime.LLMProviderOpenAI:
159-
Register{{$key}}HandlerOpenAI(s, srv)
205+
Register{{$key}}HandlerOpenAI(s, srv, opts...)
160206
case runtime.LLMProviderStandard:
161207
fallthrough
162208
default:
163-
Register{{$key}}Handler(s, srv)
209+
Register{{$key}}Handler(s, srv, opts...)
164210
}
165211
}
166212
{{- end }}
@@ -186,13 +232,35 @@ type Connect{{$serviceName}}Client interface {
186232
187233
{{- range $key, $val := .Services }}
188234
// ForwardToConnect{{$key}}Client registers a connectrpc client, to forward MCP calls to it.
189-
func ForwardToConnect{{$key}}Client(s *mcpserver.MCPServer, client Connect{{$key}}Client) {
235+
func ForwardToConnect{{$key}}Client(s *mcpserver.MCPServer, client Connect{{$key}}Client, opts ...runtime.Option) {
236+
config := runtime.NewConfig()
237+
for _, opt := range opts {
238+
opt(config)
239+
}
240+
190241
{{- range $tool_name, $tool_val := $val }}
191-
s.AddTool({{$key}}_{{$tool_name}}Tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
242+
{{$tool_name}}Tool := {{$key}}_{{$tool_name}}Tool
243+
// Add URL field to schema if ExtractURL is enabled
244+
if config.ExtractURL {
245+
{{$tool_name}}Tool = runtime.AddURLFieldToTool({{$tool_name}}Tool, config.URLFieldName, config.URLDescription)
246+
}
247+
248+
s.AddTool({{$tool_name}}Tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
192249
var req {{$tool_val.RequestType}}
193250
194251
message := request.Params.Arguments
195252
253+
// Extract URL if option is enabled
254+
if config.ExtractURL {
255+
if urlVal, ok := message[config.URLFieldName]; ok {
256+
if urlStr, ok := urlVal.(string); ok {
257+
if parsedURL, err := url.Parse(urlStr); err == nil {
258+
ctx = context.WithValue(ctx, runtime.URLOverrideKey{}, parsedURL)
259+
}
260+
}
261+
}
262+
}
263+
196264
marshaled, err := json.Marshal(message)
197265
if err != nil {
198266
return nil, err
@@ -219,13 +287,35 @@ func ForwardToConnect{{$key}}Client(s *mcpserver.MCPServer, client Connect{{$key
219287
220288
{{- range $key, $val := .Services }}
221289
// ForwardTo{{$key}}Client registers a gRPC client, to forward MCP calls to it.
222-
func ForwardTo{{$key}}Client(s *mcpserver.MCPServer, client {{$key}}Client) {
290+
func ForwardTo{{$key}}Client(s *mcpserver.MCPServer, client {{$key}}Client, opts ...runtime.Option) {
291+
config := runtime.NewConfig()
292+
for _, opt := range opts {
293+
opt(config)
294+
}
295+
223296
{{- range $tool_name, $tool_val := $val }}
224-
s.AddTool({{$key}}_{{$tool_name}}Tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
297+
{{$tool_name}}Tool := {{$key}}_{{$tool_name}}Tool
298+
// Add URL field to schema if ExtractURL is enabled
299+
if config.ExtractURL {
300+
{{$tool_name}}Tool = runtime.AddURLFieldToTool({{$tool_name}}Tool, config.URLFieldName, config.URLDescription)
301+
}
302+
303+
s.AddTool({{$tool_name}}Tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
225304
var req {{$tool_val.RequestType}}
226305
227306
message := request.Params.Arguments
228307
308+
// Extract URL if option is enabled
309+
if config.ExtractURL {
310+
if urlVal, ok := message[config.URLFieldName]; ok {
311+
if urlStr, ok := urlVal.(string); ok {
312+
if parsedURL, err := url.Parse(urlStr); err == nil {
313+
ctx = context.WithValue(ctx, runtime.URLOverrideKey{}, parsedURL)
314+
}
315+
}
316+
}
317+
}
318+
229319
marshaled, err := json.Marshal(message)
230320
if err != nil {
231321
return nil, err

pkg/runtime/base_url_override.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package runtime
2+
3+
import (
4+
"encoding/json"
5+
6+
"github.com/mark3labs/mcp-go/mcp"
7+
)
8+
9+
// URLOverrideKey is the context key for storing the parsed URL override
10+
type URLOverrideKey struct{}
11+
12+
// Option defines functional options for MCP functions
13+
type Option func(*config)
14+
15+
type config struct {
16+
ExtractURL bool
17+
URLFieldName string
18+
URLDescription string
19+
}
20+
21+
// WithBaseURLProperty enables extracting a URL override from the specified field name
22+
// in request arguments and setting the parsed URL in context.
23+
func WithBaseURLProperty(fieldName, description string) Option {
24+
return func(c *config) {
25+
c.ExtractURL = true
26+
c.URLFieldName = fieldName
27+
c.URLDescription = description
28+
}
29+
}
30+
31+
// NewConfig creates a new config instance
32+
func NewConfig() *config {
33+
return &config{}
34+
}
35+
36+
// AddURLFieldToTool modifies a tool's schema to include an optional URL override field
37+
func AddURLFieldToTool(tool mcp.Tool, fieldName, description string) mcp.Tool {
38+
// Parse the existing schema
39+
var schema map[string]interface{}
40+
if err := json.Unmarshal(tool.RawInputSchema, &schema); err != nil {
41+
// If we can't parse the schema, return the original tool
42+
return tool
43+
}
44+
45+
// Add URL field to properties
46+
if properties, ok := schema["properties"].(map[string]interface{}); ok {
47+
properties[fieldName] = map[string]interface{}{
48+
"type": "string",
49+
"format": "uri",
50+
"description": description,
51+
}
52+
} else {
53+
// If no properties exist, create them
54+
schema["properties"] = map[string]interface{}{
55+
fieldName: map[string]interface{}{
56+
"type": "string",
57+
"format": "uri",
58+
"description": description,
59+
},
60+
}
61+
}
62+
63+
// Marshal the modified schema back
64+
modifiedSchema, err := json.Marshal(schema)
65+
if err != nil {
66+
// If marshaling fails, return the original tool
67+
return tool
68+
}
69+
70+
// Create a new tool with the modified schema
71+
modifiedTool := tool
72+
modifiedTool.RawInputSchema = json.RawMessage(modifiedSchema)
73+
return modifiedTool
74+
}

0 commit comments

Comments
 (0)