Skip to content

Commit 10b1375

Browse files
authored
Merge pull request #19 from redpanda-data/jb/middleware
refactor: generalize URL override to generic extra properties
2 parents ae698be + e8bf844 commit 10b1375

11 files changed

Lines changed: 1900 additions & 125 deletions

File tree

README.md

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

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

117+
### Extra properties
118+
119+
It's possible to add extra properties to MCP tools, that are not in the proto. These are written into context.
120+
121+
122+
```go
123+
// Enable URL override with custom field name and description
124+
option := runtime.WithExtraProperties(
125+
runtime.ExtraProperty{
126+
Name: "base_url",
127+
Description: "Base URL for the API",
128+
Required: true,
129+
ContextKey: MyURLOverrideKey{},
130+
},
131+
)
132+
133+
// Use with any generated function
134+
testdatamcp.RegisterTestServiceHandler(mcpServer, &srv, option)
135+
testdatamcp.ForwardToTestServiceClient(mcpServer, client, option)
136+
```
137+
117138
## LLM Provider Compatibility
118139

119140
The generator now creates both standard MCP and OpenAI-compatible handlers automatically. You can choose which to use at runtime:
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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+
"context"
19+
"encoding/json"
20+
"testing"
21+
22+
"github.com/mark3labs/mcp-go/mcp"
23+
mcpserver "github.com/mark3labs/mcp-go/server"
24+
. "github.com/onsi/gomega"
25+
"github.com/redpanda-data/protoc-gen-go-mcp/pkg/runtime"
26+
testdata "github.com/redpanda-data/protoc-gen-go-mcp/pkg/testdata/gen/go/testdata"
27+
)
28+
29+
func TestExtraPropertiesSchemaModification(t *testing.T) {
30+
g := NewWithT(t)
31+
32+
// Create a test tool with a basic schema (simulate what would be generated)
33+
originalSchema := map[string]interface{}{
34+
"type": "object",
35+
"properties": map[string]interface{}{
36+
"name": map[string]interface{}{
37+
"type": "string",
38+
},
39+
"description": map[string]interface{}{
40+
"type": "string",
41+
},
42+
"labels": map[string]interface{}{
43+
"type": "object",
44+
"additionalProperties": map[string]interface{}{
45+
"type": "string",
46+
},
47+
},
48+
"tags": map[string]interface{}{
49+
"type": "array",
50+
"items": map[string]interface{}{"type": "string"},
51+
},
52+
},
53+
"required": []string{"name"},
54+
}
55+
56+
schemaBytes, err := json.Marshal(originalSchema)
57+
g.Expect(err).ToNot(HaveOccurred())
58+
59+
originalTool := mcp.Tool{
60+
Name: "test_CreateItem",
61+
Description: "Creates a new item",
62+
RawInputSchema: json.RawMessage(schemaBytes),
63+
}
64+
65+
// Verify it doesn't have base_url field initially
66+
originalProperties := originalSchema["properties"].(map[string]interface{})
67+
g.Expect(originalProperties).ToNot(HaveKey("base_url"))
68+
69+
// Add base_url field to the tool
70+
extraProps := []runtime.ExtraProperty{
71+
{
72+
Name: "base_url",
73+
Description: "Base URL for the API",
74+
Required: true,
75+
ContextKey: "base_url_key",
76+
},
77+
}
78+
modifiedTool := runtime.AddExtraPropertiesToTool(originalTool, extraProps)
79+
80+
// Parse the modified schema
81+
var modifiedSchema map[string]interface{}
82+
err = json.Unmarshal(modifiedTool.RawInputSchema, &modifiedSchema)
83+
g.Expect(err).ToNot(HaveOccurred())
84+
85+
// Verify the base_url field was added
86+
modifiedProperties := modifiedSchema["properties"].(map[string]interface{})
87+
g.Expect(modifiedProperties).To(HaveKey("base_url"))
88+
89+
urlField := modifiedProperties["base_url"].(map[string]interface{})
90+
g.Expect(urlField["type"]).To(Equal("string"))
91+
g.Expect(urlField).ToNot(HaveKey("format")) // No special format handling
92+
g.Expect(urlField["description"]).To(Equal("Base URL for the API"))
93+
94+
// Verify original fields are still there
95+
g.Expect(modifiedProperties).To(HaveKey("name"))
96+
g.Expect(modifiedProperties).To(HaveKey("description"))
97+
g.Expect(modifiedProperties).To(HaveKey("labels"))
98+
g.Expect(modifiedProperties).To(HaveKey("tags"))
99+
100+
// Verify the base_url field was added to required fields
101+
originalRequired := originalSchema["required"].([]string)
102+
modifiedRequired := modifiedSchema["required"].([]interface{})
103+
g.Expect(len(modifiedRequired)).To(Equal(len(originalRequired) + 1))
104+
g.Expect(modifiedRequired).To(ContainElement("base_url"))
105+
}
106+
107+
func TestExtraPropertiesSchemaModificationWithCustomField(t *testing.T) {
108+
g := NewWithT(t)
109+
110+
// Create a test tool with a basic schema (simulate what would be generated)
111+
originalSchema := map[string]interface{}{
112+
"type": "object",
113+
"properties": map[string]interface{}{
114+
"name": map[string]interface{}{
115+
"type": "string",
116+
},
117+
"description": map[string]interface{}{
118+
"type": "string",
119+
},
120+
"labels": map[string]interface{}{
121+
"type": "object",
122+
"additionalProperties": map[string]interface{}{
123+
"type": "string",
124+
},
125+
},
126+
"tags": map[string]interface{}{
127+
"type": "array",
128+
"items": map[string]interface{}{"type": "string"},
129+
},
130+
},
131+
"required": []string{"name"},
132+
}
133+
134+
schemaBytes, err := json.Marshal(originalSchema)
135+
g.Expect(err).ToNot(HaveOccurred())
136+
137+
originalTool := mcp.Tool{
138+
Name: "test_CreateItem",
139+
Description: "Creates a new item",
140+
RawInputSchema: json.RawMessage(schemaBytes),
141+
}
142+
143+
// Parse the original schema to verify it doesn't have custom field
144+
originalProperties := originalSchema["properties"].(map[string]interface{})
145+
g.Expect(originalProperties).ToNot(HaveKey("api_url"))
146+
147+
// Add custom field to the tool
148+
extraProps := []runtime.ExtraProperty{
149+
{
150+
Name: "api_url",
151+
Description: "Custom API endpoint URL",
152+
Required: true,
153+
ContextKey: "base_url_key",
154+
},
155+
}
156+
modifiedTool := runtime.AddExtraPropertiesToTool(originalTool, extraProps)
157+
158+
// Parse the modified schema
159+
var modifiedSchema map[string]interface{}
160+
err = json.Unmarshal(modifiedTool.RawInputSchema, &modifiedSchema)
161+
g.Expect(err).ToNot(HaveOccurred())
162+
163+
// Verify the custom field was added
164+
modifiedProperties := modifiedSchema["properties"].(map[string]interface{})
165+
g.Expect(modifiedProperties).To(HaveKey("api_url"))
166+
g.Expect(modifiedProperties).ToNot(HaveKey("base_url")) // Should not have default base_url field
167+
168+
customField := modifiedProperties["api_url"].(map[string]interface{})
169+
g.Expect(customField["type"]).To(Equal("string"))
170+
g.Expect(customField).ToNot(HaveKey("format")) // No special format handling
171+
g.Expect(customField["description"]).To(Equal("Custom API endpoint URL"))
172+
173+
// Verify original fields are still there
174+
g.Expect(modifiedProperties).To(HaveKey("name"))
175+
g.Expect(modifiedProperties).To(HaveKey("description"))
176+
g.Expect(modifiedProperties).To(HaveKey("labels"))
177+
g.Expect(modifiedProperties).To(HaveKey("tags"))
178+
179+
// Verify the api_url field was added to required fields
180+
originalRequired := originalSchema["required"].([]string)
181+
modifiedRequired := modifiedSchema["required"].([]interface{})
182+
g.Expect(len(modifiedRequired)).To(Equal(len(originalRequired) + 1))
183+
g.Expect(modifiedRequired).To(ContainElement("api_url"))
184+
}
185+
186+
// testServer implements TestServiceServer and tracks context values
187+
type testServer struct {
188+
lastURLString string
189+
}
190+
191+
func (t *testServer) CreateItem(ctx context.Context, in *testdata.CreateItemRequest) (*testdata.CreateItemResponse, error) {
192+
// Check if API URL is set in context (using the custom context key)
193+
if urlVal := ctx.Value("base_url_key"); urlVal != nil {
194+
if urlStr, ok := urlVal.(string); ok {
195+
t.lastURLString = urlStr
196+
}
197+
}
198+
199+
return &testdata.CreateItemResponse{
200+
Id: "item-123",
201+
}, nil
202+
}
203+
204+
func (t *testServer) GetItem(ctx context.Context, in *testdata.GetItemRequest) (*testdata.GetItemResponse, error) {
205+
return &testdata.GetItemResponse{
206+
Item: &testdata.Item{
207+
Id: in.GetId(),
208+
Name: "Retrieved item",
209+
},
210+
}, nil
211+
}
212+
213+
func (t *testServer) ProcessWellKnownTypes(ctx context.Context, in *testdata.ProcessWellKnownTypesRequest) (*testdata.ProcessWellKnownTypesResponse, error) {
214+
return &testdata.ProcessWellKnownTypesResponse{
215+
Message: "Processed well-known types",
216+
}, nil
217+
}
218+
219+
func TestExtraPropertiesContextIntegration(t *testing.T) {
220+
g := NewWithT(t)
221+
222+
server := &testServer{}
223+
224+
// Create an MCP server
225+
mcpServer := mcpserver.NewMCPServer("test-server", "1.0.0")
226+
227+
// Create a mock tool with extra properties (simulating what the generated code would do)
228+
originalSchema := map[string]interface{}{
229+
"type": "object",
230+
"properties": map[string]interface{}{
231+
"name": map[string]interface{}{
232+
"type": "string",
233+
},
234+
},
235+
"required": []string{"name"},
236+
}
237+
238+
schemaBytes, err := json.Marshal(originalSchema)
239+
g.Expect(err).ToNot(HaveOccurred())
240+
241+
baseTool := mcp.Tool{
242+
Name: "testdata_TestService_CreateItem",
243+
Description: "Creates a new item",
244+
RawInputSchema: json.RawMessage(schemaBytes),
245+
}
246+
247+
// Add extra properties to the tool (simulating the generated registration code)
248+
extraProps := []runtime.ExtraProperty{
249+
{
250+
Name: "api_url",
251+
Description: "API base URL",
252+
Required: true,
253+
ContextKey: "base_url_key",
254+
},
255+
}
256+
modifiedTool := runtime.AddExtraPropertiesToTool(baseTool, extraProps)
257+
258+
// Register the tool with a handler that simulates the generated handler logic
259+
mcpServer.AddTool(modifiedTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
260+
// Extract extra properties (simulating generated code)
261+
message := request.Params.Arguments
262+
for _, prop := range extraProps {
263+
if propVal, ok := message[prop.Name]; ok {
264+
ctx = context.WithValue(ctx, prop.ContextKey, propVal)
265+
}
266+
}
267+
268+
// Create a mock request from the arguments
269+
req := &testdata.CreateItemRequest{
270+
Name: message["name"].(string),
271+
}
272+
273+
// Call the server implementation
274+
resp, err := server.CreateItem(ctx, req)
275+
if err != nil {
276+
return nil, err
277+
}
278+
279+
// Return mock response
280+
return mcp.NewToolResultText(`{"id": "` + resp.Id + `"}`), nil
281+
})
282+
283+
// Simulate an MCP call_tool request message
284+
callToolMessage := map[string]interface{}{
285+
"jsonrpc": "2.0",
286+
"id": 1,
287+
"method": "tools/call",
288+
"params": map[string]interface{}{
289+
"name": "testdata_TestService_CreateItem",
290+
"arguments": map[string]interface{}{
291+
"name": "test item",
292+
"api_url": "https://api.example.com:8080/v1",
293+
},
294+
},
295+
}
296+
297+
// Marshal the message to JSON
298+
messageBytes, err := json.Marshal(callToolMessage)
299+
g.Expect(err).ToNot(HaveOccurred())
300+
301+
// Handle the message through the MCP server
302+
response := mcpServer.HandleMessage(context.Background(), json.RawMessage(messageBytes))
303+
g.Expect(response).ToNot(BeNil())
304+
305+
// Verify the URL string was set in context and received by server
306+
g.Expect(server.lastURLString).To(Equal("https://api.example.com:8080/v1"))
307+
}

0 commit comments

Comments
 (0)