package mcp import ( "context" "encoding/json" "fmt" "strings" "codey/internal/tools" ) // namePrefix and nameSep spell a tool's registered name as // mcp____. It is the convention Claude Code and Codex share, so // a prompt or a hook written for either names the same tool here. The double // underscore is what keeps the three parts apart when a server or tool name // has a single one in it. const ( namePrefix = "mcp__" nameSep = "__" ) // maxToolNameLen is the API's ceiling on a tool name. A longer one is refused // at registration rather than sent, because the API's refusal would come back // against the whole request and take every other tool down with it. const maxToolNameLen = 64 // emptySchema is the input schema for a tool whose server sent none. The API // requires an object schema on every tool, and a server that takes no // arguments may well leave the field out. var emptySchema = json.RawMessage(`{"type":"object","properties":{}}`) // mcpTool is one server tool wearing Codey's Tool interface. type mcpTool struct { c *client server string remote string name string desc string schema json.RawMessage } // newTool builds the adapter for info, or returns an error if the name it // would register under cannot be sent to the API. func newTool(c *client, server string, info toolInfo) (tools.Tool, error) { if info.Name == "" { return nil, fmt.Errorf("server listed a tool with no name") } name := toolName(server, info.Name) if len(name) > maxToolNameLen { return nil, fmt.Errorf("tool %q: registered name %q is longer than the API's %d-character limit", info.Name, name, maxToolNameLen) } desc := strings.TrimSpace(info.Description) if desc == "" { // The API rejects a tool with no description, and a server that sent // none has still told us what the tool is called and where it lives, // which is something for the model to go on. desc = fmt.Sprintf("The %s tool of the %s MCP server.", info.Name, server) } schema := info.InputSchema if len(schema) == 0 || string(schema) == "null" { schema = emptySchema } return &mcpTool{c: c, server: server, remote: info.Name, name: name, desc: desc, schema: schema}, nil } // toolName is the registered name for tool on server, with any character // the API's name grammar does not allow replaced by an underscore. Server // names come from the config and tool names from the server; neither was // written with ^[a-zA-Z0-9_-]{1,64}$ in mind. func toolName(server, tool string) string { return namePrefix + sanitize(server) + nameSep + sanitize(tool) } func sanitize(s string) string { var b strings.Builder b.Grow(len(s)) for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': b.WriteRune(r) default: b.WriteByte('_') } } return b.String() } func (t *mcpTool) Name() string { return t.name } func (t *mcpTool) Description() string { return t.desc } func (t *mcpTool) InputSchema() json.RawMessage { return t.schema } // Execute forwards the call and renders what comes back. The arguments are // passed through untouched: the schema they were validated against is the // server's, and the server is the one to say whether they fit it. func (t *mcpTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { res, err := t.c.callTool(ctx, t.remote, raw) if err != nil { return "", fmt.Errorf("%s: %w", t.name, err) } var result callResult if err := json.Unmarshal(res, &result); err != nil { return "", fmt.Errorf("%s: server sent a malformed result: %w", t.name, err) } text := result.render() if result.IsError { // isError is the server saying the tool ran and failed, as distinct // from a protocol error. Either way the model needs to see a failure, // and the text is the server's account of it. return "", fmt.Errorf("%s: %s", t.name, text) } return text, nil } // callResult is what tools/call returns. type callResult struct { Content []contentItem `json:"content"` StructuredContent json.RawMessage `json:"structuredContent"` IsError bool `json:"isError"` } // contentItem is one element of the content array. The fields are the // union of the text, image, audio, resource and resource_link shapes; Type // says which apply. type contentItem struct { Type string `json:"type"` Text string `json:"text"` Data string `json:"data"` MimeType string `json:"mimeType"` URI string `json:"uri"` Name string `json:"name"` Resource *struct { URI string `json:"uri"` MimeType string `json:"mimeType"` Text string `json:"text"` Blob string `json:"blob"` } `json:"resource"` } // render flattens the content array to the string the model reads. Text is // carried verbatim. Binary content is described rather than carried: a // screenshot is megabytes of base64 that the model cannot read as text, and // the transcript, the log and the context window would all pay for it. The // registry has a mechanism for handing the model real image blocks; wiring // it up here is deliberately left for later. func (r callResult) render() string { var parts []string for _, item := range r.Content { switch item.Type { case "text": parts = append(parts, item.Text) case "image": parts = append(parts, fmt.Sprintf("[image: %s, about %s; images from MCP tools are described, not shown]", orUnknown(item.MimeType), approxSize(item.Data))) case "audio": parts = append(parts, fmt.Sprintf("[audio: %s, about %s; not played]", orUnknown(item.MimeType), approxSize(item.Data))) case "resource": if item.Resource == nil { parts = append(parts, "[resource with no content]") continue } res := item.Resource switch { case res.Text != "": parts = append(parts, fmt.Sprintf("[resource %s]\n%s", res.URI, res.Text)) case res.Blob != "": parts = append(parts, fmt.Sprintf("[resource %s: %s, about %s of binary data; not shown]", res.URI, orUnknown(res.MimeType), approxSize(res.Blob))) default: parts = append(parts, fmt.Sprintf("[resource %s: empty]", res.URI)) } case "resource_link": line := "[resource link: " + item.URI if item.Name != "" { line += " — " + item.Name } if item.Text != "" { line += ": " + item.Text } parts = append(parts, line+"]") default: parts = append(parts, fmt.Sprintf("[content of unsupported type %q]", item.Type)) } } // A server that fills structuredContent may leave the content array // empty; the spec asks it to mirror the one in the other, but does not // make it. The JSON is the fallback rather than the default because a // server that sent both meant the text for reading. if len(parts) == 0 && len(r.StructuredContent) > 0 && string(r.StructuredContent) != "null" { parts = append(parts, string(r.StructuredContent)) } if len(parts) == 0 { return "(no output)" } return strings.Join(parts, "\n") } func orUnknown(mime string) string { if mime == "" { return "unknown type" } return mime } // approxSize is the decoded size of base64 data, in round units. Three // bytes per four characters is close enough for a caption. func approxSize(b64 string) string { n := len(b64) / 4 * 3 switch { case n >= 1<<20: return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) case n >= 1<<10: return fmt.Sprintf("%d KB", n>>10) default: return fmt.Sprintf("%d bytes", n) } }