-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathmcp.go
More file actions
298 lines (268 loc) · 8.44 KB
/
Copy pathmcp.go
File metadata and controls
298 lines (268 loc) · 8.44 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
package cogito
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
"github.com/tmc/langchaingo/jsonschema"
)
type mcpTool struct {
name, description string
inputSchema toolInputSchema
session *mcp.ClientSession
ctx context.Context
props map[string]jsonschema.Definition
}
func (t *mcpTool) Tool() openai.Tool {
return openai.Tool{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: t.name,
Description: t.description,
Parameters: jsonschema.Definition{
Type: jsonschema.Object,
Properties: t.props,
Required: t.inputSchema.Required,
},
},
}
}
func (t *mcpTool) Execute(args map[string]any) (string, any, error) {
// Call a tool on the server.
params := &mcp.CallToolParams{
Name: t.name,
Arguments: args,
}
res, err := t.session.CallTool(t.ctx, params)
if err != nil {
xlog.Error("CallTool failed: %v", err)
return "", nil, err
}
result := contentToString(res.Content)
if res.IsError {
xlog.Error("tool failed", "result", result)
return result, nil, errors.New("tool failed: " + result)
}
return result, res, nil
}
// contentToString flattens the content blocks of an MCP tool result into a
// single textual representation that can be fed back to the model. Non-text
// blocks (images, audio, resources) are summarized with a descriptive marker
// instead of being asserted to *mcp.TextContent, which would panic and crash
// the host process when a tool returns media (see mudler/LocalAI#10101).
func contentToString(content []mcp.Content) string {
result := ""
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
result += v.Text
case *mcp.ImageContent:
result += fmt.Sprintf("[image content (%s), %d bytes]", v.MIMEType, len(v.Data))
case *mcp.AudioContent:
result += fmt.Sprintf("[audio content (%s), %d bytes]", v.MIMEType, len(v.Data))
case *mcp.ResourceLink:
result += fmt.Sprintf("[resource link: %s]", v.URI)
case *mcp.EmbeddedResource:
switch {
case v.Resource == nil:
result += "[embedded resource]"
case v.Resource.Text != "":
result += v.Resource.Text
default:
result += fmt.Sprintf("[embedded resource: %s]", v.Resource.URI)
}
default:
xlog.Warn("Unhandled MCP content type", "type", fmt.Sprintf("%T", c))
}
}
return result
}
func (t *mcpTool) Close() {
if err := t.session.Close(); err != nil {
xlog.Warn("Failed to close MCP session", "error", err)
}
}
type toolInputSchema struct {
Type string `json:"type"`
Properties map[string]interface{} `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
}
// CoerceNullableTypes is an exported alias for the same workaround so
// downstream tests can verify their own MCP servers stay compatible
// with the import path. Most callers won't need it.
func CoerceNullableTypes(props map[string]any) { coerceNullableTypes(props) }
// coerceNullableTypes recursively walks a property bag and rewrites any
// JSON-Schema 2020-12 "type": ["null", "X"] into "type": "X". The
// downstream langchaingo/jsonschema.Definition we unmarshal into has
// Type as a single string, so an unflattened type-array would fail
// the unmarshal and silently drop the tool. Picks the first non-null
// member; falls back to the first member if all are null.
//
// Recurses into every nested schema location a `type` field can
// appear: properties, items, oneOf/anyOf/allOf members, $defs/
// definitions, additionalProperties, patternProperties.
func coerceNullableTypes(props map[string]any) {
if props == nil {
return
}
for _, raw := range props {
coerceSchema(raw)
}
}
// coerceSchema applies the type-array → string rewrite to a single
// schema node, then recurses into every nested schema location.
func coerceSchema(node any) {
obj, ok := node.(map[string]any)
if !ok {
return
}
if t, ok := obj["type"].([]any); ok {
pick := ""
for _, m := range t {
s, ok := m.(string)
if !ok || s == "null" {
continue
}
pick = s
break
}
if pick == "" && len(t) > 0 {
if s, ok := t[0].(string); ok {
pick = s
}
}
if pick != "" {
obj["type"] = pick
}
}
// Object properties — map of name → schema.
if nested, ok := obj["properties"].(map[string]any); ok {
coerceNullableTypes(nested)
}
// patternProperties — same shape as properties, just regex-keyed.
if nested, ok := obj["patternProperties"].(map[string]any); ok {
coerceNullableTypes(nested)
}
// $defs / definitions — JSON-Schema named schema bag.
if nested, ok := obj["$defs"].(map[string]any); ok {
coerceNullableTypes(nested)
}
if nested, ok := obj["definitions"].(map[string]any); ok {
coerceNullableTypes(nested)
}
// Single nested schema fields.
coerceSchema(obj["items"])
coerceSchema(obj["additionalProperties"])
coerceSchema(obj["contains"])
coerceSchema(obj["not"])
coerceSchema(obj["if"])
coerceSchema(obj["then"])
coerceSchema(obj["else"])
coerceSchema(obj["propertyNames"])
// Composition keywords — arrays of schemas.
for _, key := range []string{"oneOf", "anyOf", "allOf"} {
arr, ok := obj[key].([]any)
if !ok {
continue
}
for _, member := range arr {
coerceSchema(member)
}
}
// "items" can also be an array (tuple validation in older drafts).
if arr, ok := obj["items"].([]any); ok {
for _, member := range arr {
coerceSchema(member)
}
}
// "prefixItems" (2020-12 tuple).
if arr, ok := obj["prefixItems"].([]any); ok {
for _, member := range arr {
coerceSchema(member)
}
}
}
func mcpPromptsFromTransport(ctx context.Context, session *mcp.ClientSession, arguments map[string]string) ([]openai.ChatCompletionMessage, error) {
prompts, err := session.ListPrompts(ctx, nil)
if err != nil {
return nil, err
}
promptsList := []openai.ChatCompletionMessage{}
for _, prompt := range prompts.Prompts {
p, err := session.GetPrompt(ctx, &mcp.GetPromptParams{Name: prompt.Name, Arguments: arguments})
if err != nil {
return nil, err
}
for _, message := range p.Messages {
switch message.Content.(type) {
case *mcp.TextContent:
promptsList = append(promptsList, openai.ChatCompletionMessage{
Role: string(message.Role),
Content: message.Content.(*mcp.TextContent).Text,
})
}
}
}
return promptsList, nil
}
// MCPToolFilter is invoked once per (session, tool) pair during the
// initial tool-discovery pass. Return false to drop the tool from the
// agent's discovered set (the LLM never sees it). A nil filter is
// equivalent to "always allow".
type MCPToolFilter = func(session *mcp.ClientSession, toolName string) bool
// probe the MCP remote and generate tools that are compliant with cogito
func mcpToolsFromTransport(ctx context.Context, session *mcp.ClientSession, filter MCPToolFilter) ([]ToolDefinitionInterface, error) {
allTools := []ToolDefinitionInterface{}
tools, err := session.ListTools(ctx, nil)
if err != nil {
xlog.Error("Error listing tools: %v", err)
return nil, err
}
for _, tool := range tools.Tools {
if filter != nil && !filter(session, tool.Name) {
continue
}
dat, err := json.Marshal(tool.InputSchema)
if err != nil {
xlog.Error("Error marshalling input schema: %v", err)
continue
}
var inputSchema toolInputSchema
err = json.Unmarshal(dat, &inputSchema)
if err != nil {
xlog.Error("Error unmarshalling input schema: %v", err)
continue
}
// Some MCP servers (e.g. modelcontextprotocol/go-sdk v1.4+)
// emit JSON Schema 2020-12 "type": ["null", "array"] for
// nullable fields like Go []string slices. langchaingo's
// jsonschema.Definition.Type is a single string, so the
// unmarshal would fail and silently drop the entire tool.
// Coerce any type-array to its non-null string member before
// unmarshalling so those tools stay discoverable.
coerceNullableTypes(inputSchema.Properties)
props := map[string]jsonschema.Definition{}
dat, err = json.Marshal(inputSchema.Properties)
if err != nil {
xlog.Error("Error marshalling input schema: %v", err)
continue
}
err = json.Unmarshal(dat, &props)
if err != nil {
xlog.Error("Error unmarshalling input schema properties: %v", err)
continue
}
allTools = append(allTools, &mcpTool{
name: tool.Name,
description: tool.Description,
session: session,
ctx: ctx,
props: props,
inputSchema: inputSchema,
})
}
return allTools, nil
}