From 18f5113fcf4638efc42aee4b27ca7e302cf276c0 Mon Sep 17 00:00:00 2001 From: wklken Date: Thu, 22 Oct 2026 20:07:50 +0800 Subject: [PATCH 1/5] feat(mcp): add isolated configuration and lossless argument foundations --- AGENTS.md | 2 + docs/design.md | 20 +- internal/mcpclient/arguments.go | 169 ++++++++++++ internal/mcpclient/arguments_test.go | 285 +++++++++++++++++++++ internal/mcpclient/auth.go | 128 +++++++++ internal/mcpclient/config.go | 194 ++++++++++++++ internal/mcpclient/config_test.go | 276 ++++++++++++++++++++ internal/mcpclient/json.go | 121 +++++++++ internal/mcpclient/mcpclient_suite_test.go | 31 +++ internal/mcpclient/schema.go | 167 ++++++++++++ 10 files changed, 1392 insertions(+), 1 deletion(-) create mode 100644 internal/mcpclient/arguments.go create mode 100644 internal/mcpclient/arguments_test.go create mode 100644 internal/mcpclient/auth.go create mode 100644 internal/mcpclient/config.go create mode 100644 internal/mcpclient/config_test.go create mode 100644 internal/mcpclient/json.go create mode 100644 internal/mcpclient/mcpclient_suite_test.go create mode 100644 internal/mcpclient/schema.go diff --git a/AGENTS.md b/AGENTS.md index 8fdb39a..75da00a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,7 @@ Rules that should stay true: | `internal/validate` | Shared validation helpers for names and header values | | `internal/utils` | Small reusable helpers such as CSV parsing | | `internal/auth` | OAuth device-flow placeholder | +| `internal/mcpclient` | MCP configuration, server-local auth, and lossless argument parsing; SDK/CLI integration pending | | `internal/update` | Self-update implementation | ### `cmd/*` @@ -478,6 +479,7 @@ When finishing a code or CLI-surface change, verify all relevant items: The repo still has some intentionally incomplete areas: - OAuth device-flow placeholder in `internal/auth/device_flow.go` +- MCP support currently has only local foundations in `internal/mcpclient`; no `bk-cli mcp` command or SDK transport is registered. See Issue #42 and the MCP section of `docs/design.md` before extending it. - `--quiet` / `-q` flag is not yet implemented (noted in `docs/design.md`) The design intent for current work should be inferred from `docs/design.md`, the README files, existing tests, and the current implementation. diff --git a/docs/design.md b/docs/design.md index 48c73f3..23c73f7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -256,7 +256,25 @@ flowchart TD 它的目标是让 Go-implemented action 不需要各自重建命令管道,而是复用一致的运行时解析、请求发送和输出约定。 -### 5.8 `cmd/*` +### 5.8 `internal/mcpclient`(MCP 本地基础,命令未接入) + +[Issue #42](https://github.com/TencentBlueKing/bk-cli/issues/42) 分阶段实现。当前仅提供独立的配置、认证和参数处理,不注册 `bk-cli mcp`,不连接 MCP server,不引入 MCP SDK 依赖。 + +- `LoadServer` 只选择一个显式文件或 `config.BaseDirectory()/mcp.json`,相对路径按当前工作目录解析。严格校验整个 `mcpServers` 文件,拒绝未知字段、重复 JSON key、不合法的 server 名、URL 和 header;不读取 active context 或凭据。 +- `ResolveHeaders` 单独展开所选 server 的 `${ENV_NAME}`,`$${` 表示字面 `${`;不递归展开、执行 shell、加载 `.env` 或回写文件。静态帮助可只加载结构,不要求环境变量存在。 +- 认证只来自所选 server 的 headers。支持 Bearer、应用 code/secret + ticket 或 token、独立 access_token,以及其他合法认证 scheme 和自定义头。拒绝两种认证头并存、空票据和不合法的蓝鲸 JSON。`MaskedHeaders` 对所有配置值返回固定 `***`;错误不回显配置内容。 +- 禁止配置覆盖 HTTP 连接控制、内容类型、Host、长度、Accept、Last-Event-ID 和 `Mcp-*` 协议头。实际 HTTP 请求、响应头脱敏和重定向策略属于后续 transport 接入。 +- `ReadArguments` 支持内联 JSON、`@file` 和 `-` stdin,返回完整对象;重复字段、非对象、尾随内容和非法 UTF-8 均失败。数字保持 `json.Number`,不经浮点转换。 +- `Parameters` 只映射可明确表示的 string/integer/number/boolean/array/object 字段。CLI 保留名、复杂组合、引用和无法表示的字段留给完整 JSON。`BuildArguments` 拒绝重复和未知具名参数,非 nil body 完全覆盖已识别具名值,不合并、不注入 default。 +- 本地校验明确的基础类型、required、enum、嵌套 properties 和 items;数值枚举使用精确有理数比较。其他 JSON Schema 关键字不在本阶段验证范围,不访问远程 `$ref`。后续命令层仍须校验 CLI token 结构、执行模式和帮助路径。 + +本地资源上限:单个配置/arguments 输入 16 MiB,JSON 容器嵌套最多 128 层,单个展开后的 header 值 16 MiB。需要数值类型/枚举校验时,数字字面量最多 4096 字符、显式指数绝对值最多 10000;超出校验范围报错,已接受的数字保留原始表示。这些是本地输入和校验限制,不替代后续 JSON/SSE 响应、累计目录和分页上限。 + +SDK 接入前提(2026-10-22 验证):官方主线提交 `12e768b461fd06ea80b0d87e14ac7fa0d24c606a` 的直接 JSON 解码仍将 Schema、tool/result/content 元数据中的 `9007199254740993` 改成 `9007199254740992`;同一工具结果的 structuredContent 保留原数值。上游 [#1239](https://github.com/modelcontextprotocol/go-sdk/pull/1239) 已合并,[#1311](https://github.com/modelcontextprotocol/go-sdk/pull/1311) 当时仍是未合并 Draft PR。依赖策略为只接受官方已合并提交,不采用第三方 PR head,也不降低数字保真验收要求。 + +后续需在修复合并后重新锁定 SDK 版本/提交,验证 Schema/元数据/参数/结果/jq 全链路数字精度及实际 `tools/call` 次数,再接入协议版本、响应上限、分页、超时、结果未知状态、命令与 mock 集成。当前本地单元测试不代表 MCP wire 兼容或真实服务联调通过。 + +### 5.9 `cmd/*` 负责 Cobra 命令定义和交互层。 diff --git a/internal/mcpclient/arguments.go b/internal/mcpclient/arguments.go new file mode 100644 index 0000000..a7f4baf --- /dev/null +++ b/internal/mcpclient/arguments.go @@ -0,0 +1,169 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "io" + "os" + "regexp" + "sort" + "strings" + "unicode/utf8" +) + +// NamedArgument preserves occurrence order so repeated flags can be rejected. +// The command layer must validate CLI token structure before passing values. +type NamedArgument struct{ Name, Value string } + +// Parameter describes a schema property that can safely become a CLI flag. +type Parameter struct { + Name string + Type string + Description string + Required bool +} + +// ReadArguments reads one complete arguments object from inline JSON, @file, or +// stdin (-). The caller must skip this function for help. Paths are cwd-relative. +func ReadArguments(source string, stdin io.Reader) (map[string]any, error) { + var reader io.Reader = strings.NewReader(source) + if source == "-" { + if stdin == nil { + return nil, argumentError() + } + reader = stdin + } else if path, ok := strings.CutPrefix(source, "@"); ok { + file, err := os.Open(path) + if err != nil { + return nil, argumentError() + } + defer func() { _ = file.Close() }() + reader = file + } + raw, err := decodeJSON(reader) + if err != nil { + return nil, argumentError() + } + object, ok := raw.(map[string]any) + if !ok { + return nil, argumentError() + } + return object, nil +} + +var parameterName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`) + +// ReservedParameter reports names owned or reserved by the MCP CLI contract. +func ReservedParameter(name string) bool { + switch name { + case "help", "h", "check", "tools", "config", "body", "body-schema", "timeout", "jq", "context", + "dry-run", "verbose", "v", "insecure", "format", "header", "stage", "tenant", "quiet", "q": + return true + } + return false +} + +// Parameters returns stable, unambiguous flag definitions. All other properties +// remain available in complete JSON input; no schema defaults are injected. +func Parameters(schema map[string]any) []Parameter { + if complexSchema(schema) { + return nil + } + properties, _ := schema["properties"].(map[string]any) + result := make([]Parameter, 0, len(properties)) + for name, raw := range properties { + property, ok := raw.(map[string]any) + if !ok || !parameterName.MatchString(name) || ReservedParameter(name) || complexSchema(property) { + continue + } + kind, _ := property["type"].(string) + switch kind { + case "string", "integer", "number", "boolean", "array", "object": + default: + continue + } + description, _ := property["description"].(string) + result = append( + result, + Parameter{Name: name, Type: kind, Description: description, Required: required(schema, name)}, + ) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +func complexSchema(schema map[string]any) bool { + for _, key := range []string{"$ref", "$dynamicRef", "oneOf", "anyOf", "allOf", "not", "if", "then", "else"} { + if _, ok := schema[key]; ok { + return true + } + } + return false +} + +func required(schema map[string]any, name string) bool { + names, _ := schema["required"].([]any) + for _, value := range names { + if value == name { + return true + } + } + return false +} + +// BuildArguments maps explicit values and validates the final object. A non-nil +// body completely overrides recognized named values; names must still be valid +// and unique. Schemas and bodies must come from lossless JSON decoding. +func BuildArguments(schema map[string]any, named []NamedArgument, body map[string]any) (map[string]any, error) { + parameters := make(map[string]Parameter) + for _, parameter := range Parameters(schema) { + parameters[parameter.Name] = parameter + } + seen := make(map[string]bool) + arguments := make(map[string]any) + for _, argument := range named { + parameter, ok := parameters[argument.Name] + if !ok || seen[argument.Name] { + return nil, argumentError() + } + seen[argument.Name] = true + if body != nil { + continue + } + if parameter.Type == "string" && !utf8.ValidString(argument.Value) { + return nil, argumentError() + } + var value any = argument.Value + if parameter.Type != "string" { + var err error + value, err = decodeJSON(strings.NewReader(argument.Value)) + if err != nil { + return nil, argumentError() + } + } + arguments[argument.Name] = value + } + if body != nil { + arguments = body + } + if !validateValue(schema, arguments, 0) { + return nil, argumentError() + } + return arguments, nil +} diff --git a/internal/mcpclient/arguments_test.go b/internal/mcpclient/arguments_test.go new file mode 100644 index 0000000..df8e395 --- /dev/null +++ b/internal/mcpclient/arguments_test.go @@ -0,0 +1,285 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("SECRET") } + +var _ = Describe("MCP arguments", func() { + It("accepts the exact input limit and rejects one byte more", func() { + body := "{}" + strings.Repeat(" ", MaxInputBytes-2) + _, err := ReadArguments(body, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = ReadArguments(body+" ", nil) + Expect(err).To(HaveOccurred()) + }) + It("leaves root-level composite schemas on the body path", func() { + schema := map[string]any{ + "oneOf": []any{}, + "properties": map[string]any{"x": map[string]any{"type": "string"}}, + } + Expect(Parameters(schema)).To(BeEmpty()) + _, err := BuildArguments(schema, nil, map[string]any{"x": "ok"}) + Expect(err).NotTo(HaveOccurred()) + }) + It("rejects numeric validation beyond its explicit resource bounds", func() { + schema := map[string]any{"properties": map[string]any{"x": map[string]any{"type": "number"}}} + for _, value := range []string{strings.Repeat("1", 4097), "1e10001", "1e999999999999999999999999"} { + _, err := BuildArguments(schema, []NamedArgument{{"x", value}}, nil) + Expect(err).To(HaveOccurred()) + } + }) + DescribeTable( + "compares structured enum values exactly", + func(value string, valid bool) { + enum := []any{map[string]any{"n": json.Number("1")}, []any{json.Number("2"), true}, nil} + schema := map[string]any{"properties": map[string]any{"x": map[string]any{"enum": enum}}} + body, err := ReadArguments(`{"x":`+value+`}`, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = BuildArguments(schema, nil, body) + if valid { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + Entry("object", `{"n":1.0}`, true), + Entry("array", `[2.0,true]`, true), + Entry("null", `null`, true), + Entry("wrong key", `{"other":1}`, false), + Entry("extra key", `{"n":1,"extra":0}`, false), + Entry("wrong numeric type", `{"n":"1"}`, false), + Entry("wrong number", `{"n":2}`, false), + Entry( + "wrong element", + `[2,false]`, + false, + ), + Entry("wrong length", `[2]`, false), + Entry("scalar", `false`, false), + ) + It("rejects invalid UTF-8 instead of replacing data", func() { + _, err := ReadArguments("{\"value\":\""+string([]byte{0xff})+"\"}", nil) + Expect(err).To(HaveOccurred()) + }) + It("rejects invalid UTF-8 named strings unless body overrides them", func() { + schema := map[string]any{"properties": map[string]any{"text": map[string]any{"type": "string"}}} + named := []NamedArgument{{"text", string([]byte{0xff})}} + _, err := BuildArguments(schema, named, nil) + Expect(err).To(MatchError(ContainSubstring("invalid_mcp_arguments"))) + body := map[string]any{"text": "valid"} + arguments, err := BuildArguments(schema, named, body) + Expect(err).NotTo(HaveOccurred()) + Expect(arguments).To(Equal(body)) + }) + It("preserves exact number literals and shell-sensitive data across every body source", func() { + body := `{"big":9007199254740993,"decimal":0.123456789012345678901,"exponent":1.234567890123456789e99,"text":"00123 中文 $(whoami) ` + "`x`" + `\n\"\\","zero":0,"false":false,"empty":""}` + path := filepath.Join(GinkgoT().TempDir(), "args.json") + Expect(os.WriteFile(path, []byte(body), 0o600)).To(Succeed()) + for _, source := range []string{body, "@" + path, "-"} { + args, err := ReadArguments(source, strings.NewReader(body)) + Expect(err).NotTo(HaveOccurred()) + Expect(args["big"]).To(Equal(json.Number("9007199254740993"))) + Expect(args["decimal"]).To(Equal(json.Number("0.123456789012345678901"))) + Expect(args["exponent"]).To(Equal(json.Number("1.234567890123456789e99"))) + encoded, err := json.Marshal(args) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).To(ContainSubstring(`"big":9007199254740993`)) + } + }) + DescribeTable( + "rejects malformed or ambiguous bodies", + func(body string) { + _, err := ReadArguments(body, strings.NewReader(body)) + Expect(err).To(MatchError(ContainSubstring("invalid_mcp_arguments"))) + }, + Entry("empty", ""), + Entry("array", `[]`), + Entry("null", `null`), + Entry("string", `"secret"`), + Entry("number", `42`), + Entry( + "bad JSON", + `{"x":`, + ), + Entry("duplicate", `{"x":1,"x":2}`), + Entry("nested duplicate", `{"x":[{"a":1,"a":2}]}`), + Entry( + "trailing JSON", + `{} {}`, + ), + Entry("trailing junk", `{} SECRET`), + Entry("missing file", "@/missing-mcp-arguments-42"), + Entry("no file path", "@"), + Entry("deep", strings.Repeat(`{"x":`, 130)+`0`+strings.Repeat(`}`, 130)), + ) + It("propagates read failure safely and bounds input", func() { + _, err := ReadArguments("-", failingReader{}) + Expect(err).To(MatchError(ContainSubstring("invalid_mcp_arguments"))) + Expect(err.Error()).NotTo(ContainSubstring("SECRET")) + _, err = ReadArguments("-", nil) + Expect(err).To(HaveOccurred()) + _, err = ReadArguments( + "-", + io.LimitReader(strings.NewReader(strings.Repeat(" ", MaxInputBytes+1)), MaxInputBytes+1), + ) + Expect(err).To(HaveOccurred()) + }) + It("maps only explicit fields without injecting defaults", func() { + schema := map[string]any{"type": "object", "properties": map[string]any{ + "s": map[string]any{"type": "string"}, "i": map[string]any{"type": "integer"}, + "n": map[string]any{"type": "number"}, "b": map[string]any{"type": "boolean"}, + "a": map[string]any{"type": "array"}, "o": map[string]any{"type": "object"}, + "absent": map[string]any{"type": "boolean", "default": true}, + }} + args, err := BuildArguments( + schema, + []NamedArgument{ + {"s", "00123"}, + {"i", "9007199254740993"}, + {"n", "1.234567890123456789e-12"}, + {"b", "false"}, + {"a", "[1,2]"}, + {"o", `{"zero":0}`}, + }, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(args).To(HaveLen(6)) + Expect(args["s"]).To(Equal("00123")) + Expect(args["i"]).To(Equal(json.Number("9007199254740993"))) + Expect(args["n"]).To(Equal(json.Number("1.234567890123456789e-12"))) + Expect(args["b"]).To(BeFalse()) + Expect(args).NotTo(HaveKey("absent")) + }) + It("uses body as a complete override but still rejects unknown or repeated names", func() { + schema := map[string]any{ + "type": "object", + "required": []any{"count"}, + "properties": map[string]any{"count": map[string]any{"type": "integer"}}, + } + body := map[string]any{"count": json.Number("0")} + args, err := BuildArguments(schema, []NamedArgument{{"count", "invalid overridden value"}}, body) + Expect(err).NotTo(HaveOccurred()) + Expect(args).To(Equal(body)) + _, err = BuildArguments(schema, []NamedArgument{{"typo", "1"}}, body) + Expect(err).To(HaveOccurred()) + _, err = BuildArguments(schema, []NamedArgument{{"count", "1"}, {"count", "2"}}, body) + Expect(err).To(HaveOccurred()) + _, err = BuildArguments(schema, nil, map[string]any{}) + Expect(err).To(HaveOccurred()) + }) + It("keeps reserved and ambiguous fields accessible through body", func() { + schema := map[string]any{"properties": map[string]any{ + "body": map[string]any{"type": "string"}, + "bad name": map[string]any{"type": "string"}, + "union": map[string]any{ + "type": []any{"string", "null"}, + }, + "ref": map[string]any{"$ref": "https://example.com/schema", "type": "string"}, + "flag": map[string]any{"type": "boolean", "description": "A flag"}, + }, "required": []any{"body", "flag"}} + fields := Parameters(schema) + Expect( + fields, + ).To( + Equal([]Parameter{{Name: "flag", Type: "boolean", Description: "A flag", Required: true}}), + ) + body := map[string]any{"body": "value", "flag": false, "bad name": "x", "union": nil, "ref": "x"} + _, err := BuildArguments(schema, nil, body) + Expect(err).NotTo(HaveOccurred()) + _, err = BuildArguments(schema, []NamedArgument{{"body", "value"}}, nil) + Expect(err).To(HaveOccurred()) + }) + DescribeTable( + "checks basic types and enums on final values", + func(kind, value string, valid bool) { + schema := map[string]any{"properties": map[string]any{"x": map[string]any{"type": kind}}} + _, err := BuildArguments(schema, []NamedArgument{{"x", value}}, nil) + if valid { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + Entry("zero", "integer", "0", true), + Entry("integral exponent", "integer", "1e3", true), + Entry( + "fraction", + "integer", + "1.1", + false, + ), + Entry("large negative exponent", "integer", "1e-999999999", false), + Entry( + "true", + "boolean", + "true", + true, + ), + Entry("bool token", "boolean", "yes", false), + Entry("null", "object", "null", false), + Entry( + "wrong collection", + "array", + "{}", + false, + ), + Entry("number string", "number", `"3"`, false), + Entry("invalid number", "number", "01", false), + ) + It("compares enum numbers without float rounding, recursively validates objects and arrays", func() { + schema := map[string]any{ + "properties": map[string]any{"items": map[string]any{"type": "array", "items": map[string]any{ + "type": "object", + "required": []any{"n"}, + "properties": map[string]any{ + "n": map[string]any{ + "type": "number", + "enum": []any{json.Number("9007199254740993")}, + }, + }, + }}}, + } + for _, test := range []struct { + value string + valid bool + }{{`[{"n":9007199254740993}]`, true}, {`[{"n":9007199254740993.0}]`, true}, {`[{"n":9007199254740992}]`, false}, {`[{}]`, false}, {`["x"]`, false}} { + _, err := BuildArguments(schema, []NamedArgument{{"items", test.value}}, nil) + if test.valid { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + } + }) +}) diff --git a/internal/mcpclient/auth.go b/internal/mcpclient/auth.go new file mode 100644 index 0000000..d3d8da4 --- /dev/null +++ b/internal/mcpclient/auth.go @@ -0,0 +1,128 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "net/http" + "os" + "regexp" + "strings" +) + +var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func expandHeader(value string) (string, error) { + var expanded strings.Builder + for i := 0; i < len(value); { + switch { + case strings.HasPrefix(value[i:], "$${"): + expanded.WriteString("${") + i += 3 + case strings.HasPrefix(value[i:], "${"): + end := strings.IndexByte(value[i+2:], '}') + if end < 0 { + return "", configError() + } + name := value[i+2 : i+2+end] + if !environmentName.MatchString(name) { + return "", configError() + } + replacement, ok := os.LookupEnv(name) + if !ok { + return "", &Error{ + "missing_environment_variable", + "A configured MCP header environment variable is not set", + } + } + if len(replacement) > MaxInputBytes-expanded.Len() { + return "", configError() + } + expanded.WriteString(replacement) + i += end + 3 + default: + expanded.WriteByte(value[i]) + i++ + } + if expanded.Len() > MaxInputBytes { + return "", configError() + } + } + return expanded.String(), nil +} + +func validateAuth(headers http.Header) error { + _, hasAuthorization := headers["Authorization"] + _, hasBlueKing := headers[http.CanonicalHeaderKey("X-Bkapi-Authorization")] + if hasAuthorization && hasBlueKing { + return &Error{"mcp_auth_conflict", "Authorization and X-Bkapi-Authorization cannot be combined"} + } + if hasAuthorization { + value := headers.Get("Authorization") + fields := strings.Fields(value) + if len(fields) == 0 { + return configError() + } + if strings.EqualFold(fields[0], "Bearer") && (len(fields) != 2 || !validBearer(fields[1])) { + return configError() + } + } + if hasBlueKing { + return validateBlueKingAuth(headers.Get("X-Bkapi-Authorization")) + } + return nil +} + +var bearerToken = regexp.MustCompile(`^[A-Za-z0-9._~+/-]+=*$`) + +func validBearer(value string) bool { return bearerToken.MatchString(value) } + +func validateBlueKingAuth(value string) error { + raw, err := decodeJSON(strings.NewReader(value)) + if err != nil { + return configError() + } + fields, ok := raw.(map[string]any) + if !ok { + return configError() + } + auth := make(map[string]string) + for key, value := range fields { + switch key { + case "access_token", "bk_app_code", "bk_app_secret", "bk_token", "bk_ticket": + default: + return configError() + } + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return configError() + } + auth[key] = text + } + if auth["access_token"] != "" { + if len(auth) != 1 { + return configError() + } + return nil + } + if len(auth) != 3 || auth["bk_app_code"] == "" || auth["bk_app_secret"] == "" || + (auth["bk_token"] == "") == (auth["bk_ticket"] == "") { + return configError() + } + return nil +} diff --git a/internal/mcpclient/config.go b/internal/mcpclient/config.go new file mode 100644 index 0000000..953b703 --- /dev/null +++ b/internal/mcpclient/config.go @@ -0,0 +1,194 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "unicode" + + "github.com/TencentBlueKing/bk-cli/internal/config" + "github.com/TencentBlueKing/bk-cli/internal/validate" +) + +// Server is the selected, structurally validated server. Headers remain literal +// until ResolveHeaders is called, so static help requires no credentials. +type Server struct { + Name string + ConfigFile string + URL string + Headers map[string]string +} + +// LoadServer selects exactly one file, validates its complete structure, and +// returns the exact named server without resolving credentials or reading context. +func LoadServer(path, name string) (*Server, error) { + if path == "" { + path = filepath.Join(config.BaseDirectory(), "mcp.json") + } + path, err := filepath.Abs(path) + if err != nil { + return nil, configError() + } + file, err := os.Open(path) + if os.IsNotExist(err) { + return nil, &Error{"mcp_config_not_found", "MCP configuration file not found"} + } + if err != nil { + return nil, configError() + } + defer func() { _ = file.Close() }() + raw, err := decodeJSON(file) + if err != nil { + return nil, configError() + } + document, ok := raw.(map[string]any) + if !ok || len(document) != 1 { + return nil, configError() + } + servers, ok := document["mcpServers"].(map[string]any) + if !ok { + return nil, configError() + } + var selected *Server + for alias, value := range servers { + server, err := parseServer(alias, value) + if err != nil { + return nil, err + } + if alias == name { + selected = server + } + } + if selected == nil { + return nil, &Error{"mcp_server_not_found", "Named MCP server not found in selected file"} + } + selected.ConfigFile = path + return selected, nil +} + +func parseServer(name string, raw any) (*Server, error) { + if name == "" || strings.HasPrefix(name, "-") || strings.ContainsFunc(name, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsControl(r) + }) { + return nil, configError() + } + object, ok := raw.(map[string]any) + if !ok { + return nil, configError() + } + for key := range object { + if key != "type" && key != "url" && key != "headers" { + return nil, configError() + } + } + transport, ok := object["type"].(string) + if !ok || transport != "http" { + return nil, configError() + } + endpoint, ok := object["url"].(string) + if !ok || !validEndpoint(endpoint) { + return nil, configError() + } + server := &Server{Name: name, URL: endpoint, Headers: make(map[string]string)} + if rawHeaders, exists := object["headers"]; exists { + headers, ok := rawHeaders.(map[string]any) + if !ok { + return nil, configError() + } + for key, value := range headers { + text, ok := value.(string) + if !ok { + return nil, configError() + } + server.Headers[key] = text + } + } + if err := validateHeaders(server.Headers); err != nil { + return nil, err + } + return server, nil +} + +func validEndpoint(endpoint string) bool { + parsed, err := url.Parse(endpoint) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && + parsed.Hostname() != "" && parsed.User == nil && !strings.Contains(endpoint, "#") && parsed.Opaque == "" +} + +func validateHeaders(headers map[string]string) error { + seen := make(map[string]bool) + for name, value := range headers { + lower := strings.ToLower(name) + if seen[lower] || managedHeader(lower) || validate.ValidateHeaderName(name) != nil || + validate.ValidateHeaderValue(value) != nil { + return configError() + } + seen[lower] = true + } + return nil +} + +func managedHeader(name string) bool { + if strings.HasPrefix(name, "mcp-") { + return true + } + switch name { + case "host", "content-length", "content-type", "accept", "connection", "keep-alive", + "proxy-authenticate", "proxy-authorization", "proxy-connection", "te", "trailer", + "transfer-encoding", "upgrade", "last-event-id": + return true + } + return false +} + +// MaskedHeaders exposes names only. Every configured value uses the same mask. +func (s Server) MaskedHeaders() map[string]string { + headers := make(map[string]string, len(s.Headers)) + for name := range s.Headers { + headers[name] = "***" + } + return headers +} + +// ResolveHeaders expands only explicit references in this server's headers and +// validates authentication. It never loads context or fallback credentials. +func (s Server) ResolveHeaders() (http.Header, error) { + if err := validateHeaders(s.Headers); err != nil { + return nil, err + } + headers := make(http.Header) + for name, value := range s.Headers { + expanded, err := expandHeader(value) + if err != nil { + return nil, err + } + if validate.ValidateHeaderValue(expanded) != nil { + return nil, configError() + } + headers.Set(name, expanded) + } + if err := validateAuth(headers); err != nil { + return nil, err + } + return headers, nil +} diff --git a/internal/mcpclient/config_test.go b/internal/mcpclient/config_test.go new file mode 100644 index 0000000..2357580 --- /dev/null +++ b/internal/mcpclient/config_test.go @@ -0,0 +1,276 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MCP configuration", func() { + var dir string + BeforeEach(func() { + dir = GinkgoT().TempDir() + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) + }) + write := func(body string) string { + path := filepath.Join(dir, "mcp.json") + Expect(os.WriteFile(path, []byte(body), 0o600)).To(Succeed()) + return path + } + It("selects one file without reading even a corrupt active context", func() { + Expect(os.WriteFile(filepath.Join(dir, "current"), []byte("../broken"), 0o600)).To(Succeed()) + path := write(`{"mcpServers":{"test":{"type":"http","url":"https://example.com/path/?q=1"}}}`) + selected, err := LoadServer("", "test") + Expect(err).NotTo(HaveOccurred()) + Expect(selected.ConfigFile).To(Equal(path)) + Expect(selected.URL).To(Equal("https://example.com/path/?q=1")) + headers, err := selected.ResolveHeaders() + Expect(err).NotTo(HaveOccurred()) + Expect(headers).To(BeEmpty()) + _, err = LoadServer(filepath.Join(dir, "missing.json"), "test") + Expect(err).To(MatchError(ContainSubstring("mcp_config_not_found"))) + }) + It("keeps help independent of selected or unrelated missing variables", func() { + path := write( + `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":{"Authorization":"Bearer ${MCP_TEST_SECRET}"}},"other":{"type":"http","url":"https://example.net","headers":{"X-Key":"${MCP_TEST_MISSING}"}}}}`, + ) + GinkgoT().Setenv("MCP_TEST_SECRET", "secret") + selected, err := LoadServer(path, "test") + Expect(err).NotTo(HaveOccurred()) + headers, err := selected.ResolveHeaders() + Expect(err).NotTo(HaveOccurred()) + Expect(headers.Get("Authorization")).To(Equal("Bearer secret")) + Expect(selected.MaskedHeaders()).To(Equal(map[string]string{"Authorization": "***"})) + content, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("${MCP_TEST_SECRET}")) + }) + DescribeTable( + "rejects invalid structure without reflecting secrets", + func(body string) { + _, err := LoadServer(write(body), "test") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid_mcp_config")) + Expect(err.Error()).NotTo(ContainSubstring("SECRET")) + }, + Entry("bad JSON", `{"SECRET`), + Entry("null", `null`), + Entry("missing servers", `{}`), + Entry("unknown field", `{"mcpServers":{},"SECRET":true}`), + Entry("wrong format", `{"servers":{}}`), + Entry("duplicate keys", `{"mcpServers":{},"mcpServers":{}}`), + Entry("duplicate server", `{"mcpServers":{"test":{},"test":{}}}`), + Entry("null server", `{"mcpServers":{"test":null}}`), + Entry( + "null headers", + `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":null}}}`, + ), + Entry( + "unknown server field", + `{"mcpServers":{"test":{"type":"http","url":"https://example.com","SECRET":true}}}`, + ), + Entry("unsupported transport", `{"mcpServers":{"test":{"type":"stdio","url":"https://example.com"}}}`), + Entry("non-http", `{"mcpServers":{"test":{"type":"http","url":"file:///SECRET"}}}`), + Entry("userinfo", `{"mcpServers":{"test":{"type":"http","url":"https://user:SECRET@example.com"}}}`), + Entry("fragment", `{"mcpServers":{"test":{"type":"http","url":"https://example.com/#SECRET"}}}`), + Entry("invalid name", `{"mcpServers":{"-SECRET":{"type":"http","url":"https://example.com"}}}`), + Entry( + "header aliases", + `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":{"X-Key":"SECRET","x-key":"SECRET"}}}}`, + ), + Entry( + "managed header", + `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":{"Mcp-Session-Id":"SECRET"}}}}`, + ), + Entry( + "invalid header name", + `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":{"SECRET\n":"x"}}}}`, + ), + Entry( + "invalid header value", + `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":{"X-Key":"SECRET\n"}}}}`, + ), + ) + It("uses explicit files, exact names, and safe errors", func() { + path := write(`{"mcpServers":{"Case":{"type":"http","url":"http://example.com"}}}`) + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", filepath.Join(dir, "unused")) + _, err := LoadServer(path, "Case") + Expect(err).NotTo(HaveOccurred()) + _, err = LoadServer(path, "case") + Expect(err).To(MatchError(ContainSubstring("mcp_server_not_found"))) + _, err = LoadServer(dir, "test") + Expect(err).To(MatchError(ContainSubstring("invalid_mcp_config"))) + }) + It("bounds configuration input", func() { + _, err := LoadServer(write(strings.Repeat(" ", MaxInputBytes+1)), "test") + Expect(err).To(MatchError(ContainSubstring("invalid_mcp_config"))) + }) + It("uses the home directory when the config directory override is empty", func() { + GinkgoT().Setenv("HOME", dir) + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", "") + Expect(os.Mkdir(filepath.Join(dir, ".bk-cli"), 0o700)).To(Succeed()) + path := filepath.Join(dir, ".bk-cli", "mcp.json") + Expect( + os.WriteFile( + path, + []byte(`{"mcpServers":{"test":{"type":"http","url":"http://example.com"}}}`), + 0o600, + ), + ).To( + Succeed(), + ) + server, err := LoadServer("", "test") + Expect(err).NotTo(HaveOccurred()) + Expect(server.ConfigFile).To(Equal(path)) + }) +}) + +var _ = Describe("Server header resolution", func() { + DescribeTable( + "rejects managed or repeated headers even on a constructed server", + func(headers map[string]string) { + _, err := (Server{Headers: headers}).ResolveHeaders() + Expect(err).To(HaveOccurred()) + }, + Entry("Host", map[string]string{"Host": "other.example"}), + Entry("Connection", map[string]string{"Connection": "keep-alive"}), + Entry("duplicates", map[string]string{"X-Key": "a", "x-key": "b"}), + ) + DescribeTable( + "validates authentication shapes", + func(headers map[string]string, code string) { + server := Server{Headers: headers} + resolved, err := server.ResolveHeaders() + if code != "" { + Expect(err).To(MatchError(ContainSubstring(code))) + Expect(err.Error()).NotTo(ContainSubstring("SECRET")) + } else { + Expect(err).NotTo(HaveOccurred()) + for name, value := range headers { + Expect(resolved.Get(name)).To(Equal(value)) + } + } + }, + Entry("anonymous", map[string]string{}, ""), + Entry("personal", map[string]string{"authorization": "Bearer SECRET"}, ""), + Entry("custom scheme", map[string]string{"Authorization": "Basic SECRET", "X-API-Key": "SECRET"}, ""), + Entry( + "app ticket", + map[string]string{ + "X-Bkapi-Authorization": `{"bk_app_code":"a","bk_app_secret":"SECRET","bk_ticket":"t"}`, + }, + "", + ), + Entry( + "app token", + map[string]string{ + "X-Bkapi-Authorization": `{"bk_app_code":"a","bk_app_secret":"SECRET","bk_token":"t"}`, + }, + "", + ), + Entry("access token", map[string]string{"X-Bkapi-Authorization": `{"access_token":"SECRET"}`}, ""), + Entry( + "conflict", + map[string]string{ + "Authorization": "Bearer SECRET", + "x-bkapi-authorization": `{"access_token":"SECRET"}`, + }, + "mcp_auth_conflict", + ), + Entry("empty", map[string]string{"Authorization": ""}, "invalid_mcp_config"), + Entry("empty bearer", map[string]string{"Authorization": "bEaReR "}, "invalid_mcp_config"), + Entry( + "bearer with spaces", + map[string]string{"Authorization": "Bearer SECRET bad"}, + "invalid_mcp_config", + ), + Entry("non-object", map[string]string{"X-Bkapi-Authorization": `[]`}, "invalid_mcp_config"), + Entry("invalid JSON", map[string]string{"X-Bkapi-Authorization": `SECRET`}, "invalid_mcp_config"), + Entry("empty object", map[string]string{"X-Bkapi-Authorization": `{}`}, "invalid_mcp_config"), + Entry( + "wrong type", + map[string]string{"X-Bkapi-Authorization": `{"access_token":42}`}, + "invalid_mcp_config", + ), + Entry( + "empty token", + map[string]string{"X-Bkapi-Authorization": `{"access_token":" "}`}, + "invalid_mcp_config", + ), + Entry( + "mixed modes", + map[string]string{"X-Bkapi-Authorization": `{"access_token":"SECRET","bk_app_code":"a"}`}, + "invalid_mcp_config", + ), + Entry( + "unknown field", + map[string]string{"X-Bkapi-Authorization": `{"access_token":"SECRET","extra":"x"}`}, + "invalid_mcp_config", + ), + Entry( + "two user tokens", + map[string]string{ + "X-Bkapi-Authorization": `{"bk_app_code":"a","bk_app_secret":"SECRET","bk_ticket":"t","bk_token":"t"}`, + }, + "invalid_mcp_config", + ), + Entry( + "missing secret", + map[string]string{"X-Bkapi-Authorization": `{"bk_app_code":"a","bk_ticket":"t"}`}, + "invalid_mcp_config", + ), + ) + It("expands only explicit references, once, and supports literal escapes", func() { + GinkgoT().Setenv("MCP_TEST_A", "${MCP_TEST_B}") + GinkgoT().Setenv("MCP_TEST_B", "SECRET") + GinkgoT().Setenv("BK_CLI_PERSONAL_TOKEN", "unrelated") + server := Server{ + Headers: map[string]string{"X-Key": "${MCP_TEST_A}/${MCP_TEST_B}/$${literal}/$(whoami)"}, + } + headers, err := server.ResolveHeaders() + Expect(err).NotTo(HaveOccurred()) + Expect(headers.Get("X-Key")).To(Equal("${MCP_TEST_B}/SECRET/${literal}/$(whoami)")) + Expect(headers.Get("Authorization")).To(BeEmpty()) + }) + DescribeTable("rejects unsupported template syntax", func(value string) { + _, err := (Server{Headers: map[string]string{"X-Key": value}}).ResolveHeaders() + Expect(err).To(MatchError(ContainSubstring("invalid_mcp_config"))) + }, Entry("unterminated", "${SECRET"), Entry("client syntax", "${env:SECRET}"), Entry("default", "${SECRET:-default}"), Entry("empty", "${}"), Entry("leading digit", "${1SECRET}")) + It("rejects missing variables and expanded control characters", func() { + name := "BK_CLI_MCP_TEST_UNSET_42" + old, exists := os.LookupEnv(name) + Expect(os.Unsetenv(name)).To(Succeed()) + DeferCleanup(func() { + if exists { + Expect(os.Setenv(name, old)).To(Succeed()) + } + }) + _, err := (Server{Headers: map[string]string{"X-Key": "${" + name + "}"}}).ResolveHeaders() + Expect(err).To(MatchError(ContainSubstring("missing_environment_variable"))) + GinkgoT().Setenv("MCP_TEST_BAD", "SECRET\r\n") + _, err = (Server{Headers: map[string]string{"X-Key": "${MCP_TEST_BAD}"}}).ResolveHeaders() + Expect(err).To(MatchError(ContainSubstring("invalid_mcp_config"))) + Expect(err.Error()).NotTo(ContainSubstring("SECRET")) + }) +}) diff --git a/internal/mcpclient/json.go b/internal/mcpclient/json.go new file mode 100644 index 0000000..ec0cff4 --- /dev/null +++ b/internal/mcpclient/json.go @@ -0,0 +1,121 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +// Package mcpclient provides context-independent configuration and input handling +// for remote MCP servers. Transport integration is intentionally separate. +package mcpclient + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "unicode/utf8" +) + +// MaxInputBytes bounds local configuration and arguments before decoding. +const MaxInputBytes = 16 << 20 + +const maxJSONDepth = 128 + +// Error is a safe, machine-readable local MCP error. Messages never contain +// configuration values or JSON parser errors that could disclose credentials. +type Error struct { + Code string + Message string +} + +func (e *Error) Error() string { return e.Code + ": " + e.Message } + +func configError() error { return &Error{"invalid_mcp_config", "Invalid MCP configuration"} } + +func argumentError() error { return &Error{"invalid_mcp_arguments", "Invalid MCP arguments"} } + +// decodeJSON keeps number literals and rejects duplicate keys at every depth. +func decodeJSON(reader io.Reader) (any, error) { + data, err := io.ReadAll(io.LimitReader(reader, MaxInputBytes+1)) + if err != nil || len(data) > MaxInputBytes || !utf8.Valid(data) { + return nil, fmt.Errorf("cannot read bounded JSON input") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + value, err := decodeValue(decoder, 0) + if err != nil { + return nil, err + } + if _, err = decoder.Token(); !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("unexpected trailing JSON") + } + return value, nil +} + +func decodeValue(decoder *json.Decoder, depth int) (any, error) { + if depth > maxJSONDepth { + return nil, fmt.Errorf("JSON nesting limit exceeded") + } + token, err := decoder.Token() + if err != nil { + return nil, fmt.Errorf("invalid JSON") + } + delimiter, ok := token.(json.Delim) + if !ok { + return token, nil + } + switch delimiter { + case '{': + result := make(map[string]any) + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return nil, fmt.Errorf("invalid JSON key") + } + name, ok := key.(string) + if !ok { + return nil, fmt.Errorf("invalid JSON key") + } + if _, exists := result[name]; exists { + return nil, fmt.Errorf("duplicate JSON key") + } + value, err := decodeValue(decoder, depth+1) + if err != nil { + return nil, err + } + result[name] = value + } + if _, err := decoder.Token(); err != nil { + return nil, fmt.Errorf("invalid JSON object") + } + return result, nil + case '[': + result := make([]any, 0) + for decoder.More() { + value, err := decodeValue(decoder, depth+1) + if err != nil { + return nil, err + } + result = append(result, value) + } + if _, err := decoder.Token(); err != nil { + return nil, fmt.Errorf("invalid JSON array") + } + return result, nil + default: + return nil, fmt.Errorf("invalid JSON delimiter") + } +} diff --git a/internal/mcpclient/mcpclient_suite_test.go b/internal/mcpclient/mcpclient_suite_test.go new file mode 100644 index 0000000..fdf13ac --- /dev/null +++ b/internal/mcpclient/mcpclient_suite_test.go @@ -0,0 +1,31 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMCPClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "MCP Client Suite") +} diff --git a/internal/mcpclient/schema.go b/internal/mcpclient/schema.go new file mode 100644 index 0000000..b336998 --- /dev/null +++ b/internal/mcpclient/schema.go @@ -0,0 +1,167 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "encoding/json" + "math/big" + "reflect" + "strconv" + "strings" +) + +// Numeric bounds constrain validation cost; accepted numbers retain their wire +// spelling. This is not a floating-point range limit. +const ( + maxNumberChars = 4096 + maxNumberExponent = 10000 +) + +func numberValue(number json.Number) (*big.Rat, bool) { + text := number.String() + if len(text) > maxNumberChars { + return nil, false + } + if index := strings.IndexAny(text, "eE"); index >= 0 { + exponent, err := strconv.Atoi(text[index+1:]) + if err != nil || exponent > maxNumberExponent || exponent < -maxNumberExponent { + return nil, false + } + } + value, ok := new(big.Rat).SetString(text) + return value, ok +} + +// validateValue deliberately checks only explicit types, required properties, +// enums, object properties and array items. Remote references are never fetched. +func validateValue(schema map[string]any, value any, depth int) bool { + if depth > maxJSONDepth { + return false + } + kind, _ := schema["type"].(string) + if !matchesType(kind, value) { + return false + } + if enum, ok := schema["enum"].([]any); ok { + found := false + for _, candidate := range enum { + if equalJSON(candidate, value) { + found = true + break + } + } + if !found { + return false + } + } + switch actual := value.(type) { + case map[string]any: + names, _ := schema["required"].([]any) + for _, name := range names { + text, ok := name.(string) + if !ok { + continue + } + if _, exists := actual[text]; !exists { + return false + } + } + properties, _ := schema["properties"].(map[string]any) + for name, element := range actual { + property, _ := properties[name].(map[string]any) + if !validateValue(property, element, depth+1) { + return false + } + } + case []any: + items, _ := schema["items"].(map[string]any) + for _, element := range actual { + if !validateValue(items, element, depth+1) { + return false + } + } + } + return true +} + +func matchesType(kind string, value any) bool { + switch kind { + case "string": + _, ok := value.(string) + return ok + case "boolean": + _, ok := value.(bool) + return ok + case "object": + _, ok := value.(map[string]any) + return ok + case "array": + _, ok := value.([]any) + return ok + case "null": + return value == nil + case "number", "integer": + number, ok := value.(json.Number) + if !ok { + return false + } + rational, ok := numberValue(number) + return ok && (kind != "integer" || rational.IsInt()) + default: + return true + } +} + +func equalJSON(left, right any) bool { + switch value := left.(type) { + case json.Number: + other, ok := right.(json.Number) + if !ok { + return false + } + l, lok := numberValue(value) + r, rok := numberValue(other) + return lok && rok && l.Cmp(r) == 0 + case map[string]any: + other, ok := right.(map[string]any) + if !ok || len(value) != len(other) { + return false + } + for name, element := range value { + candidate, exists := other[name] + if !exists || !equalJSON(element, candidate) { + return false + } + } + return true + case []any: + other, ok := right.([]any) + if !ok || len(value) != len(other) { + return false + } + for index, element := range value { + if !equalJSON(element, other[index]) { + return false + } + } + return true + default: + return reflect.DeepEqual(left, right) + } +} From f07915112259e0f4dfea3374b298c087ede07156 Mon Sep 17 00:00:00 2001 From: wklken Date: Thu, 22 Oct 2026 20:38:59 +0800 Subject: [PATCH 2/5] feat(mcp): complete remote tool discovery and single-call execution --- AGENTS.md | 11 +- README.md | 3 +- README_EN.md | 3 +- cmd/mcp/command.go | 464 ++++++++++++++++++ cmd/mcp/command_test.go | 256 ++++++++++ cmd/mcp_test.go | 48 ++ cmd/root.go | 2 + docs/design.md | 54 +- docs/user-guide.md | 105 +++- go.mod | 8 +- go.sum | 14 + internal/mcpclient/client.go | 245 +++++++++ internal/mcpclient/client_edges_test.go | 342 +++++++++++++ internal/mcpclient/client_test.go | 317 ++++++++++++ internal/mcpclient/transport.go | 378 ++++++++++++++ skills/bk-cli-mcp/SKILL.md | 78 +++ skills/bk-cli-shared/SKILL.md | 10 +- tests/integration/AGENTS.md | 1 + tests/integration/README.md | 3 +- .../integration/cases/mcp/MCP-001-basic.yaml | 41 ++ .../cases/mcp/MCP-002-auth-isolation.yaml | 33 ++ .../cases/mcp/MCP-003-arguments-errors.yaml | 35 ++ .../cases/mcp/MCP-004-pagination-timeout.yaml | 21 + .../cases/mcp/MCP-005-offline.yaml | 25 + .../integration/fixtures/config/mcp/mcp.json | 17 + tests/integration/mock_api/app.py | 45 +- 26 files changed, 2532 insertions(+), 27 deletions(-) create mode 100644 cmd/mcp/command.go create mode 100644 cmd/mcp/command_test.go create mode 100644 cmd/mcp_test.go create mode 100644 internal/mcpclient/client.go create mode 100644 internal/mcpclient/client_edges_test.go create mode 100644 internal/mcpclient/client_test.go create mode 100644 internal/mcpclient/transport.go create mode 100644 skills/bk-cli-mcp/SKILL.md create mode 100644 tests/integration/cases/mcp/MCP-001-basic.yaml create mode 100644 tests/integration/cases/mcp/MCP-002-auth-isolation.yaml create mode 100644 tests/integration/cases/mcp/MCP-003-arguments-errors.yaml create mode 100644 tests/integration/cases/mcp/MCP-004-pagination-timeout.yaml create mode 100644 tests/integration/cases/mcp/MCP-005-offline.yaml create mode 100644 tests/integration/fixtures/config/mcp/mcp.json diff --git a/AGENTS.md b/AGENTS.md index 75da00a..9fd53a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Rules that should stay true: | `internal/validate` | Shared validation helpers for names and header values | | `internal/utils` | Small reusable helpers such as CSV parsing | | `internal/auth` | OAuth device-flow placeholder | -| `internal/mcpclient` | MCP configuration, server-local auth, and lossless argument parsing; SDK/CLI integration pending | +| `internal/mcpclient` | MCP configuration, server-local auth, SDK sessions, bounded discovery and lossless arguments/results | | `internal/update` | Self-update implementation | ### `cmd/*` @@ -122,6 +122,7 @@ Rules that should stay true: | `cmd/root.go` | Root command, global flags, shared wiring | | `cmd/auth` | `auth login/status/check/logout` | | `cmd/api` | Raw API command | +| `cmd/mcp` | Independent MCP discovery, help, invocation and offline preview | | `cmd/context` | Context lifecycle commands | | `cmd/doctor.go` | Root-level environment diagnostics command | | `cmd/update` | Self-update command | @@ -138,6 +139,7 @@ Rules that should stay true: - `context` - `doctor` - `help` +- `mcp` - `update` - `version` - registered public systems via `cmd/system/register.go` @@ -314,10 +316,14 @@ When a Go-implemented action supports both named flags and raw `--body`, treat ` ## Final result projection -`api` and public system actions register `output.AddJQFlag(cmd)` locally. Go actions inherit it through `AddCommonRequestFlags` / `AddCommonRequestFlagsWithoutBody`; YAML actions register it in their builder. `jq` is reserved for generated flags. +`api`, `mcp`, and public system actions register `output.AddJQFlag(cmd)` locally. Go actions inherit it through `AddCommonRequestFlags` / `AddCommonRequestFlagsWithoutBody`; YAML actions register it in their builder. `jq` is reserved for generated flags. Write final request results through `output.WriteCommandEnvelope(cmd, env)`, after any result checks, redaction, response mutation, or pagination aggregation. Keep intermediate requests in the shared request layer without projecting them. The output helper compiles during flag parsing, buffers JSON Lines with finite execution/output budgets, bypasses failed envelopes, and preserves completed request results on projection failures. Keep unsupported commands explicit instead of adding a root flag that can be silently ignored. Detailed semantics are defined in `docs/design.md`. +## MCP command boundary + +`cmd/mcp` is a root command, not a public system. It uses only the selected `mcp.json` server headers and never resolves gateway context, credentials, stage or tenant. Keep SDK negotiation/session ownership in `internal/mcpclient`, preserve raw JSON numbers at the HTTP boundary, and never replay `tools/call`. See `docs/design.md` section 5.8 for version, resource-limit and result-state contracts. Keep MCP integration cases in `tests/integration/cases/mcp/` and user guidance in `skills/bk-cli-mcp/SKILL.md`. + ## Runtime, auth, and request behavior Detailed shared request-contract rules live in `docs/design.md`. When you add or change request behavior, treat `docs/design.md` as the source of truth for: @@ -479,7 +485,6 @@ When finishing a code or CLI-surface change, verify all relevant items: The repo still has some intentionally incomplete areas: - OAuth device-flow placeholder in `internal/auth/device_flow.go` -- MCP support currently has only local foundations in `internal/mcpclient`; no `bk-cli mcp` command or SDK transport is registered. See Issue #42 and the MCP section of `docs/design.md` before extending it. - `--quiet` / `-q` flag is not yet implemented (noted in `docs/design.md`) The design intent for current work should be inferred from `docs/design.md`, the README files, existing tests, and the current implementation. diff --git a/README.md b/README.md index 4ea4f9f..988fa60 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ ## 功能特性 +- **远程 MCP 工具**: 从独立 `mcp.json` 连接 Streamable HTTP server,使用 `bk-cli mcp --check/--tools` 发现,再显式调用工具;认证只来自该 server 的 headers。见 [MCP 使用指南](docs/user-guide.md#11-远程-mcp-工具) - **三层调用方式**: 原始 API 调用、系统子命令,以及快捷指令(未来) - **多上下文支持**: 管理多个 BlueKing 部署的凭据 - **环境自检**: 使用 `bk-cli doctor` 检查 context、凭据、URL 渲染和网关连通性,凭据值脱敏显示 - **自动化友好的输出**: 带 `ok` 字段的 JSON 信封、机器可读错误、可预测的退出码;API 响应保留 JSON 数字精度 -- **内置结果筛选**: `api` 和 system action 支持 `--jq '<表达式>'`,以完整最终 envelope 为输入,每个结果输出一行 JSON;无需安装外部 jq +- **内置结果筛选**: `api`、`mcp` 和 system action 支持 `--jq '<表达式>'`,以完整最终 envelope 为输入,每个结果输出一行 JSON;无需安装外部 jq - **灵活请求体输入**: `--body` 支持内联 JSON、`@file` 文件读取和 stdin 输入 - **丰富的系统命令**: 内置多个 BlueKing system 子命令,也保留原始 `api` 调用作为兜底能力 - **内置 Agent Skills**: 可通过 `bk-cli skills list` 和 `bk-cli skills read ` 查看随当前版本打包的使用指引,`skills read --raw` 可输出原始 Markdown diff --git a/README_EN.md b/README_EN.md index f571092..95086df 100644 --- a/README_EN.md +++ b/README_EN.md @@ -12,11 +12,12 @@ For the full design contract, see [docs/design.md](docs/design.md). ## Features +- **Remote MCP tools**: Connect to Streamable HTTP servers from a separate `mcp.json`, discover with `bk-cli mcp --check/--tools`, then explicitly call a tool. Authentication comes only from that server's headers. See the [MCP guide (Chinese)](docs/user-guide.md#11-远程-mcp-工具). - **Three layers of invocation**: raw API calls, system subcommands, and shortcuts (planned) - **Multi-context support**: manage credentials for multiple BlueKing deployments - **Environment diagnostics**: use `bk-cli doctor` to check contexts, credentials, URL rendering, and gateway connectivity, with credential values masked - **Automation-friendly output**: JSON envelopes with an `ok` field, machine-readable errors, and predictable exit codes; API responses preserve JSON number precision -- **Built-in result filtering**: `api` and system actions accept `--jq ''` over the complete final envelope, emitting one JSON value per line without an external jq installation +- **Built-in result filtering**: `api`, `mcp`, and system actions accept `--jq ''` over the complete final envelope, emitting one JSON value per line without an external jq installation - **Flexible request body input**: `--body` supports inline JSON, `@file` file reads, and stdin - **Rich system commands**: built-in BlueKing system subcommands, with raw `api` calls kept as a fallback - **Embedded agent skills**: inspect version-matched usage guidance with `bk-cli skills list` and `bk-cli skills read `; use `skills read --raw` for raw Markdown diff --git a/cmd/mcp/command.go b/cmd/mcp/command.go new file mode 100644 index 0000000..502fccf --- /dev/null +++ b/cmd/mcp/command.go @@ -0,0 +1,464 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +// Package mcp implements the server-first MCP command without gateway context. +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/TencentBlueKing/bk-cli/internal/config" + "github.com/TencentBlueKing/bk-cli/internal/mcpclient" + "github.com/TencentBlueKing/bk-cli/internal/output" +) + +type options struct { + config, body string + timeout time.Duration + check, tools, help, dryRun, verbose, insecure bool + context string + server, tool string + named []mcpclient.NamedArgument +} + +const generalHelp = `Usage: + bk-cli mcp [--config path] [--check | --tools | [arguments]] + +Configuration: $BK_CLI_CONFIG_DIR/mcp.json, otherwise ~/.bk-cli/mcp.json. +Only the selected server's configured headers are used; context credentials are never loaded. + +Examples: + bk-cli mcp report-prod --check + bk-cli mcp report-prod --tools + bk-cli mcp report-prod search --help + bk-cli mcp report-prod search --body '{"keyword":"gateway"}' + bk-cli mcp report-prod search --body @arguments.json + bk-cli mcp report-prod search --body - < arguments.json + bk-cli mcp report-prod search --body '{}' --dry-run + +Options: --config, --timeout (default 60s), --jq, --verbose/-v, --insecure, --dry-run. +Remote tools may have side effects. Discovery success does not prove business permissions. +` + +// NewCommand registers a local routing entrypoint; construction never reads +// configuration, credentials or remote schemas. +func NewCommand() *cobra.Command { + o := &options{} + cmd := &cobra.Command{ + Use: "mcp", + Short: "Discover and call configured remote MCP tools", + DisableFlagParsing: true, + SilenceUsage: true, + SilenceErrors: true, + } + flags := cmd.Flags() + flags.StringVar(&o.config, "config", "", "Use exactly this MCP JSON file") + flags.StringVar(&o.body, "body", "", "Complete arguments JSON, @file, or - for stdin") + flags.DurationVar(&o.timeout, "timeout", config.DefaultTimeout, "Total remote operation timeout") + flags.BoolVar(&o.check, "check", false, "Check connection, protocol and complete tool discovery") + flags.BoolVar(&o.tools, "tools", false, "List all tools and schemas") + flags.BoolVarP(&o.help, "help", "h", false, "Show help without calling a tool") + flags.BoolVar(&o.dryRun, "dry-run", false, "Preview a complete body without network access") + flags.BoolVarP(&o.verbose, "verbose", "v", false, "Write masked diagnostics to stderr") + flags.BoolVar(&o.insecure, "insecure", false, "Skip HTTPS certificate verification") + flags.StringVar(&o.context, "context", "", "Unsupported for MCP") + output.AddJQFlag(cmd) + cmd.SetHelpFunc(func(cmd *cobra.Command, _ []string) { _, _ = fmt.Fprint(cmd.OutOrStdout(), generalHelp) }) + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if err := o.parse(cmd, args); err != nil { + return localError(cmd, err) + } + return o.run(cmd) + } + return cmd +} + +func invalidArguments() *mcpclient.Error { + return &mcpclient.Error{Code: "invalid_mcp_arguments", Message: "Invalid MCP command arguments; use --help"} +} + +func (o *options) parse(cmd *cobra.Command, args []string) error { + common := make([]string, 0) + positionals := make([]string, 0, 2) + seen := make(map[string]bool) + for i := 0; i < len(args); i++ { + token := args[i] + if token == "--" { + positionals = append(positionals, args[i+1:]...) + break + } + if !strings.HasPrefix(token, "-") { + positionals = append(positionals, token) + continue + } + name, value, hasValue := strings.Cut(strings.TrimPrefix(token, "--"), "=") + var flag *pflag.Flag + if strings.HasPrefix(token, "--") { + flag = cmd.Flags().Lookup(name) + } else { + short, shortValue, equals := strings.Cut(strings.TrimPrefix(token, "-"), "=") + if len(short) != 1 { + return invalidArguments() + } + flag = cmd.Flags().ShorthandLookup(short) + value, hasValue = shortValue, equals + } + if flag != nil { + if seen[flag.Name] { + return invalidArguments() + } + seen[flag.Name] = true + if !hasValue { + if flag.NoOptDefVal != "" { + value = flag.NoOptDefVal + } else { + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return invalidArguments() + } + i++ + value = args[i] + } + } + common = append(common, "--"+flag.Name+"="+value) + continue + } + if !strings.HasPrefix(token, "--") || mcpclient.ReservedParameter(name) { + return &mcpclient.Error{Code: "unsupported_option", Message: "Option is not supported by MCP"} + } + if !hasValue { + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return invalidArguments() + } + i++ + value = args[i] + } + o.named = append(o.named, mcpclient.NamedArgument{Name: name, Value: value}) + } + if err := cmd.Flags().Parse(common); err != nil { + return invalidArguments() + } + return o.validateMode(cmd, positionals) +} + +func (o *options) validateMode(cmd *cobra.Command, positionals []string) error { + if cmd.Flags().Changed("context") || + (cmd.InheritedFlags().Lookup("context") != nil && cmd.InheritedFlags().Lookup("context").Changed) { + return &mcpclient.Error{ + Code: "unsupported_option", + Message: "--context is not supported by MCP; authentication comes only from server headers", + } + } + if len(positionals) > 2 || o.timeout <= 0 { + return invalidArguments() + } + if len(positionals) > 0 { + o.server = positionals[0] + } + if len(positionals) > 1 { + o.tool = positionals[1] + } + modes := 0 + if o.check { + modes++ + } + if o.tools { + modes++ + } + if o.tool != "" { + modes++ + } + if modes > 1 || ((o.check || o.tools) && (cmd.Flags().Changed("body") || len(o.named) > 0 || o.dryRun)) { + return invalidArguments() + } + if o.tool == "" && (len(o.named) > 0 || cmd.Flags().Changed("body") || o.dryRun) { + return invalidArguments() + } + if o.server == "" && modes > 0 { + return invalidArguments() + } + return nil +} + +func (o *options) run(cmd *cobra.Command) error { + if o.server == "" { + _, err := fmt.Fprint(cmd.OutOrStdout(), generalHelp) + return err + } + server, err := mcpclient.LoadServer(o.config, o.server) + if err != nil { + return localError(cmd, err) + } + if o.tool == "" && (o.help || (!o.check && !o.tools)) { + _, err := fmt.Fprintf( + cmd.OutOrStdout(), + "Server: %s\nConfig: %s\n\n%s", + server.Name, + server.ConfigFile, + generalHelp, + ) + return err + } + headers, err := server.ResolveHeaders() + if err != nil { + return localError(cmd, err) + } + if o.dryRun && !o.help { + if !cmd.Flags().Changed("body") || len(o.named) > 0 { + return localError(cmd, invalidArguments()) + } + args, err := mcpclient.ReadArguments(o.body, cmd.InOrStdin()) + if err != nil { + return localError(cmd, err) + } + env := output.DryRun( + &output.DryRunRequest{ + Method: "POST", + URL: server.URL, + Headers: server.MaskedHeaders(), + Body: map[string]any{"tool": o.tool, "arguments": args}, + }, + ) + env.Data = map[string]any{ + "server": o.server, + "tool": o.tool, + "verified_remote": false, + "unverified": []string{ + "tool existence", + "schema", + "authentication validity", + "business permissions", + }, + } + return writeResult(cmd, env) + } + if o.verbose { + masked, marshalErr := json.Marshal(server.MaskedHeaders()) + if marshalErr != nil { + return localError(cmd, marshalErr) + } + _, _ = fmt.Fprintf( + cmd.ErrOrStderr(), + "MCP server=%s config=%s headers=%s\n", + server.Name, + server.ConfigFile, + masked, + ) + } + return o.execute(cmd, server, headers) +} + +func (o *options) execute(cmd *cobra.Command, server *mcpclient.Server, headers http.Header) error { + interrupted, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer stop() + ctx, cancel := context.WithTimeout(interrupted, o.timeout) + defer cancel() + client := mcpclient.NewClient(ctx, server.URL, headers, o.insecure) + defer client.Close() + data := map[string]any{"server": o.server, "complete": false} + checks := map[string]string{ + "config": "passed", + "connection": "not_run", + "protocol": "not_run", + "tools": "not_run", + } + if o.check { + data["checks"] = checks + data["config_file"] = server.ConfigFile + data["transport"] = "http" + } + if err := client.Connect(); err != nil { + checks["connection"] = "failed" + if client.Connected() { + checks["connection"] = "passed" + checks["protocol"] = "failed" + } + return remoteFailure(cmd, client, data, err) + } + checks["connection"] = "passed" + checks["protocol"] = "passed" + data["protocol_version"] = client.ProtocolVersion() + tools, err := client.Discover() + if err != nil { + checks["tools"] = "failed" + return remoteFailure(cmd, client, data, err) + } + checks["tools"] = "passed" + if o.check || o.tools { + data["complete"] = true + data["tool_count"] = len(tools) + if o.tools { + data["tools"] = tools + data["arguments_help"] = "Reserved or complex properties require --body; use --help" + } else { + source := "none" + if len(headers) > 0 { + source = "server_headers" + } + data["authentication"] = map[string]any{"source": source, "discovery_accepted": true} + } + return writeResult(cmd, output.SuccessData(data)) + } + tool := findTool(tools, o.tool) + if tool == nil { + return localError( + cmd, + &mcpclient.Error{ + Code: "mcp_tool_not_found", + Message: "Tool not found after complete discovery", + }, + ) + } + schema, _ := tool["inputSchema"].(map[string]any) + if o.help { + allowed := make(map[string]bool) + for _, parameter := range mcpclient.Parameters(schema) { + allowed[parameter.Name] = true + } + for _, argument := range o.named { + if !allowed[argument.Name] { + return localError(cmd, invalidArguments()) + } + delete(allowed, argument.Name) + } + return toolHelp(cmd, tool, schema) + } + var body map[string]any + if cmd.Flags().Changed("body") { + body, err = mcpclient.ReadArguments(o.body, cmd.InOrStdin()) + if err != nil { + return localError(cmd, err) + } + } + arguments, err := mcpclient.BuildArguments(schema, o.named, body) + if err != nil { + return localError(cmd, err) + } + data = map[string]any{"server": o.server, "tool": o.tool} + result, err := client.Call(o.tool, arguments) + if result != nil { + data["result"] = result + } + if err != nil { + if client.CallAttempted() && !client.ResultKnown() { + data["execution_state"] = "unknown" + } + return remoteFailure(cmd, client, data, err) + } + return writeResult(cmd, output.SuccessData(data)) +} + +func findTool(tools []map[string]any, name string) map[string]any { + for _, tool := range tools { + if tool["name"] == name { + return tool + } + } + return nil +} + +func localError(cmd *cobra.Command, err error) error { + var detail *mcpclient.Error + if !errors.As(err, &detail) { + detail = invalidArguments() + } + if writeErr := output.Err( + detail.Code, + detail.Message, + "Run with --help for usage", + ).WriteJSON( + cmd.ErrOrStderr(), + ); writeErr != nil { + return writeErr + } + return &output.CLIError{ExitCode: 1, Code: detail.Code, Message: detail.Message} +} + +func remoteFailure(cmd *cobra.Command, client *mcpclient.Client, data map[string]any, err error) error { + var detail *mcpclient.Error + if !errors.As(err, &detail) { + detail = &mcpclient.Error{Code: "mcp_protocol_error", Message: "MCP operation failed"} + } + hint := "Inspect the selected server configuration and result" + if data["execution_state"] == "unknown" { + hint = "The server may have executed the tool. Verify through a business query before retrying" + } + env := output.Err(detail.Code, detail.Message, hint) + env.Data = data + env.Headers = client.Headers() + if err := writeResult(cmd, env); err != nil { + return err + } + return &output.CLIError{ExitCode: 2, Code: detail.Code, Message: detail.Message} +} + +func writeResult(cmd *cobra.Command, env *output.Envelope) error { + if err := output.WriteCommandEnvelope(cmd, env); err != nil { + var cli *output.CLIError + if errors.As(err, &cli) { + return err + } + _ = output.Err( + "output_error", + "Result output failed; the request may already have completed", + "Do not retry without checking the original result", + ).WriteJSON( + cmd.ErrOrStderr(), + ) + return &output.CLIError{ExitCode: 2, Code: "output_error", Message: "Result output failed"} + } + return nil +} + +func toolHelp(cmd *cobra.Command, tool, schema map[string]any) error { + _, _ = fmt.Fprintf( + cmd.OutOrStdout(), + "Tool: %s\nDescription: %v\n\nParameters (only explicit values are sent):\n", + tool["name"], + tool["description"], + ) + for _, parameter := range mcpclient.Parameters(schema) { + _, _ = fmt.Fprintf( + cmd.OutOrStdout(), + " --%s <%s> required=%t %s\n", + parameter.Name, + parameter.Type, + parameter.Required, + parameter.Description, + ) + } + _, _ = fmt.Fprint( + cmd.OutOrStdout(), + "\nExamples: --body '{}' | --body @arguments.json | --body -\n"+ + "Reserved/complex properties require --body. Body replaces named arguments completely.\n"+ + "Input schema (includes required fields, enum and examples):\n", + ) + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(schema) +} diff --git a/cmd/mcp/command_test.go b/cmd/mcp/command_test.go new file mode 100644 index 0000000..178f101 --- /dev/null +++ b/cmd/mcp/command_test.go @@ -0,0 +1,256 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/TencentBlueKing/bk-cli/internal/output" +) + +func TestMCP(t *testing.T) { RegisterFailHandler(Fail); RunSpecs(t, "MCP Command Suite") } + +type unreadable struct{} + +func (unreadable) Read([]byte) (int, error) { Fail("stdin must not be consumed"); return 0, io.EOF } + +var _ = Describe("MCP command", func() { + var calls, requests atomic.Int32 + var endpoint, dir string + var result string + var callDelay time.Duration + run := func(args ...string) (string, string, error) { + cmd := NewCommand() + var out, stderr bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&stderr) + cmd.SetIn(unreadable{}) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), stderr.String(), err + } + BeforeEach(func() { + calls.Store(0) + requests.Store(0) + dir = GinkgoT().TempDir() + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) + result = `{"content":[],"structuredContent":{"n":9007199254740993,"decimal":0.123456789012345678901}}` + callDelay = 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method == "DELETE" { + w.WriteHeader(204) + return + } + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "server/discover": + fmt.Fprintf( + w, + `{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"unsupported"}}`, + req.ID, + ) + case "initialize": + fmt.Fprintf( + w, + `{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"test","version":"1"}}}`, + req.ID, + ) + case "notifications/initialized", "notifications/cancelled": + w.WriteHeader(202) + case "tools/list": + fmt.Fprintf( + w, + `{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"echo","description":"Echo","inputSchema":{"type":"object","required":["text"],"properties":{"text":{"type":"string"},"limit":{"type":"integer"},"enabled":{"type":"boolean"},"body":{"type":"string"}}}}]}}`, + req.ID, + ) + case "tools/call": + calls.Add(1) + if callDelay > 0 { + select { + case <-time.After(callDelay): + case <-r.Context().Done(): + return + } + } + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":%s}`, req.ID, result) + } + })) + DeferCleanup(server.Close) + endpoint = server.URL + writeConfig(dir, endpoint) + }) + It("root and static server help do not connect or resolve credentials", func() { + for _, args := range [][]string{{}, {"--help"}, {"test"}, {"test", "-h"}} { + out, stderr, err := run(args...) + Expect(err).NotTo(HaveOccurred()) + Expect(stderr).To(BeEmpty()) + Expect(out).To(ContainSubstring("Usage:")) + } + Expect(requests.Load()).To(BeZero()) + Expect(os.Remove(filepath.Join(dir, "mcp.json"))).To(Succeed()) + _, _, err := run("--help") + Expect(err).NotTo(HaveOccurred()) + }) + It("tool help discovers but does not read a body or call", func() { + out, _, err := run("test", "echo", "--help", "--body", "-") + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring("--text required=true")) + Expect(out).To(ContainSubstring("Reserved/complex")) + Expect(calls.Load()).To(BeZero()) + }) + It("checks and lists tools using one final envelope", func() { + for _, flag := range []string{"--check", "--tools"} { + out, stderr, err := run("test", flag) + Expect(err).NotTo(HaveOccurred()) + Expect(stderr).To(BeEmpty()) + Expect(out).To(ContainSubstring(`"complete": true`)) + Expect(out).To(ContainSubstring(`"tool_count": 1`)) + } + Expect(calls.Load()).To(BeZero()) + }) + It("calls once and projects precise numbers", func() { + out, stderr, err := run( + "test", + "echo", + "--text", + "00123", + "--enabled=false", + "--limit=0", + "--jq", + ".data.result.structuredContent.decimal", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(stderr).To(BeEmpty()) + Expect(out).To(Equal("0.123456789012345678901\n")) + Expect(calls.Load()).To(Equal(int32(1))) + }) + It("does not retry a completed call when jq fails", func() { + out, stderr, err := run("test", "echo", "--text", "value", "--jq", `error("display")`) + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring(`"structuredContent"`)) + Expect(stderr).To(ContainSubstring("jq_execution_error")) + Expect(out).NotTo(ContainSubstring("execution_state")) + Expect(calls.Load()).To(Equal(int32(1))) + }) + It("reports post-discovery usage failures locally", func() { + for _, args := range [][]string{{"test", "missing"}, {"test", "echo"}, {"test", "echo", "--text=x", "--limit=no"}, {"test", "echo", "--text=x", "--typo=x"}, {"test", "echo", "--text=a", "--text=b"}} { + out, stderr, err := run(args...) + Expect(err).To(HaveOccurred()) + Expect(out).To(BeEmpty()) + Expect(stderr).To(ContainSubstring(`"ok": false`)) + var cli *output.CLIError + Expect(errors.As(err, &cli)).To(BeTrue()) + Expect(cli.ExitCode).To(Equal(1)) + } + Expect(calls.Load()).To(BeZero()) + }) + It("rejects incompatible options before any request", func() { + for _, args := range [][]string{{"test", "--context=x"}, {"test", "echo", "--check", "-h"}, {"test", "--tools", "--body={}"}, {"test", "echo", "--jq", "["}, {"test", "echo", "--timeout=0s"}, {"test", "--check", "--dry-run"}, {"test", "echo", "extra"}} { + out, stderr, err := run(args...) + Expect(err).To(HaveOccurred()) + Expect(out).To(BeEmpty()) + Expect(stderr).NotTo(BeEmpty()) + } + Expect(requests.Load()).To(BeZero()) + }) + It("previews only a complete body offline and masks every header", func() { + out, _, err := run("test", "not-discovered", "--body", `{"n":9007199254740993}`, "--dry-run") + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(`"dry_run": true`)) + Expect(out).To(ContainSubstring(`"X-Api-Key": "***"`)) + Expect(out).NotTo(ContainSubstring("secret-value")) + Expect(out).To(ContainSubstring("9007199254740993")) + for _, args := range [][]string{{"test", "echo", "--dry-run"}, {"test", "echo", "--dry-run", "--body={}", "--text=x"}} { + _, _, err = run(args...) + Expect(err).To(HaveOccurred()) + } + Expect(requests.Load()).To(BeZero()) + }) + It("keeps tool errors and unsupported outcomes visible despite jq", func() { + for _, raw := range []string{`{"content":[],"isError":true}`, `{"resultType":"input_required","inputRequests":{},"requestState":"pending"}`} { + result = raw + out, stderr, err := run("test", "echo", "--text=x", "--jq", "empty") + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring(`"ok": false`)) + Expect(out).To(ContainSubstring(`"result"`)) + Expect(out).NotTo(ContainSubstring("execution_state")) + Expect(stderr).To(BeEmpty()) + } + }) + It("marks a lost call response unknown without resending", func() { + callDelay = time.Second + out, stderr, err := run("test", "echo", "--text=x", "--timeout=100ms") + Expect(err).To(HaveOccurred()) + Expect(stderr).To(BeEmpty()) + Expect(out).To(ContainSubstring(`"execution_state": "unknown"`)) + Expect(out).To(ContainSubstring("mcp_timeout")) + Expect(calls.Load()).To(Equal(int32(1))) + }) + It("honors caller cancellation", func() { + cmd := NewCommand() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"test", "--check"}) + Expect(cmd.Execute()).To(HaveOccurred()) + Expect(calls.Load()).To(BeZero()) + }) + It("accepts stdin body once and ignores overridden named value types", func() { + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + cmd.SetIn(strings.NewReader(`{"text":"stdin"}`)) + cmd.SetArgs([]string{"test", "echo", "--limit=no", "--body", "-"}) + Expect(cmd.Execute()).To(Succeed()) + Expect(calls.Load()).To(Equal(int32(1))) + }) +}) + +func writeConfig(dir, endpoint string) { + content := fmt.Sprintf( + `{"mcpServers":{"test":{"type":"http","url":%q,"headers":{"X-Api-Key":"secret-value"}}}}`, + endpoint, + ) + Expect(os.WriteFile(filepath.Join(dir, "mcp.json"), []byte(content), 0o600)).To(Succeed()) +} diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go new file mode 100644 index 0000000..2b516d7 --- /dev/null +++ b/cmd/mcp_test.go @@ -0,0 +1,48 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package cmd + +import ( + "bytes" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MCP root integration", func() { + It("rejects an explicit context before or after the MCP command", func() { + for _, args := range [][]string{{"--context=missing", "mcp", "--help"}, {"mcp", "--context=missing", "--help"}} { + root := newRootCmd() + var out, stderr bytes.Buffer + root.SetOut(&out) + root.SetErr(&stderr) + err := executeRoot(root, args) + Expect(err).To(HaveOccurred()) + Expect(stderr.String()).To(ContainSubstring("unsupported_option")) + Expect(out.String()).To(BeEmpty()) + } + }) + It("does not apply system bool normalization to dynamic MCP parameters", func() { + root := newRootCmd() + var out bytes.Buffer + root.SetOut(&out) + Expect(executeRoot(root, []string{"mcp", "--help"})).To(Succeed()) + Expect(out.String()).To(ContainSubstring("server")) + }) +}) diff --git a/cmd/root.go b/cmd/root.go index 3070ac3..a5b9e9a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,6 +31,7 @@ import ( apicmd "github.com/TencentBlueKing/bk-cli/cmd/api" authcmd "github.com/TencentBlueKing/bk-cli/cmd/auth" ctxcmd "github.com/TencentBlueKing/bk-cli/cmd/context" + mcpcmd "github.com/TencentBlueKing/bk-cli/cmd/mcp" syscmd "github.com/TencentBlueKing/bk-cli/cmd/system" updatecmd "github.com/TencentBlueKing/bk-cli/cmd/update" internalapi "github.com/TencentBlueKing/bk-cli/internal/api" @@ -163,6 +164,7 @@ Examples: cmd.AddCommand(markRootCommand(authcmd.NewAuthCmd())) cmd.AddCommand(markRootCommand(apicmd.NewAPICmd(GetContext, IsDryRun, IsVerbose, IsInsecure))) cmd.AddCommand(markRootCommand(ctxcmd.NewContextCmd())) + cmd.AddCommand(markRootCommand(mcpcmd.NewCommand())) cmd.AddCommand(markRootCommand(newSkillsCmd())) cmd.AddCommand(markRootCommand(updatecmd.NewUpdateCmd(GetVersion, IsDryRun))) diff --git a/docs/design.md b/docs/design.md index 23c73f7..168977a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -256,23 +256,51 @@ flowchart TD 它的目标是让 Go-implemented action 不需要各自重建命令管道,而是复用一致的运行时解析、请求发送和输出约定。 -### 5.8 `internal/mcpclient`(MCP 本地基础,命令未接入) +### 5.8 `internal/mcpclient` 与 `cmd/mcp`(远程 MCP) -[Issue #42](https://github.com/TencentBlueKing/bk-cli/issues/42) 分阶段实现。当前仅提供独立的配置、认证和参数处理,不注册 `bk-cli mcp`,不连接 MCP server,不引入 MCP SDK 依赖。 +`bk-cli mcp ` 是独立根命令,对应 [Issue #42](https://github.com/TencentBlueKing/bk-cli/issues/42)。命令层负责模式、动态参数/帮助与最终输出;内部包负责配置、认证、SDK 会话、完整发现和结果分类。只支持 Streamable HTTP 的 JSON / SSE 响应,不启用 stdio、旧 HTTP+SSE、OAuth、resources/prompts、自动交互或 task 轮询。 -- `LoadServer` 只选择一个显式文件或 `config.BaseDirectory()/mcp.json`,相对路径按当前工作目录解析。严格校验整个 `mcpServers` 文件,拒绝未知字段、重复 JSON key、不合法的 server 名、URL 和 header;不读取 active context 或凭据。 -- `ResolveHeaders` 单独展开所选 server 的 `${ENV_NAME}`,`$${` 表示字面 `${`;不递归展开、执行 shell、加载 `.env` 或回写文件。静态帮助可只加载结构,不要求环境变量存在。 -- 认证只来自所选 server 的 headers。支持 Bearer、应用 code/secret + ticket 或 token、独立 access_token,以及其他合法认证 scheme 和自定义头。拒绝两种认证头并存、空票据和不合法的蓝鲸 JSON。`MaskedHeaders` 对所有配置值返回固定 `***`;错误不回显配置内容。 -- 禁止配置覆盖 HTTP 连接控制、内容类型、Host、长度、Accept、Last-Event-ID 和 `Mcp-*` 协议头。实际 HTTP 请求、响应头脱敏和重定向策略属于后续 transport 接入。 -- `ReadArguments` 支持内联 JSON、`@file` 和 `-` stdin,返回完整对象;重复字段、非对象、尾随内容和非法 UTF-8 均失败。数字保持 `json.Number`,不经浮点转换。 -- `Parameters` 只映射可明确表示的 string/integer/number/boolean/array/object 字段。CLI 保留名、复杂组合、引用和无法表示的字段留给完整 JSON。`BuildArguments` 拒绝重复和未知具名参数,非 nil body 完全覆盖已识别具名值,不合并、不注入 default。 -- 本地校验明确的基础类型、required、enum、嵌套 properties 和 items;数值枚举使用精确有理数比较。其他 JSON Schema 关键字不在本阶段验证范围,不访问远程 `$ref`。后续命令层仍须校验 CLI token 结构、执行模式和帮助路径。 +配置与输入契约: -本地资源上限:单个配置/arguments 输入 16 MiB,JSON 容器嵌套最多 128 层,单个展开后的 header 值 16 MiB。需要数值类型/枚举校验时,数字字面量最多 4096 字符、显式指数绝对值最多 10000;超出校验范围报错,已接受的数字保留原始表示。这些是本地输入和校验限制,不替代后续 JSON/SSE 响应、累计目录和分页上限。 +- `LoadServer` 只选择显式 `--config` 文件或 `config.BaseDirectory()/mcp.json`;相对配置路径及 `--body @file` 均按当前工作目录解析。严格校验整个文件,拒绝未知字段、重复 key、不合法的 server 名、URL 和 header,不读取 active context 或凭据。 +- `ResolveHeaders` 只展开所选 server 的 `${ENV_NAME}`,`$${` 表示字面 `${`;不递归展开、执行 shell、加载 `.env` 或回写。根帮助不读配置,server 静态帮助不展开环境变量;tool 帮助仅连接并发现,不读取 body/stdin 或执行工具。 +- 支持 Bearer、应用 code/secret + ticket 或 token、独立 access_token、其他合法 scheme 和自定义头。拒绝两种认证头并存、空票据或非法蓝鲸 JSON。所有实际 HTTP 请求使用同一份已解析 headers;不跟随重定向,默认校验 HTTPS 证书。 +- 禁止配置覆盖 Host、内容类型、长度、Accept、连接控制、Last-Event-ID 和 `Mcp-*` 协议头。诊断中的全部配置 header 值固定为 `***`;响应同名头及已知认证头也脱敏。原始工具业务结果不做通用内容 DLP。 +- `ReadArguments` 将内联 JSON、`@file`、stdin 解析为完整对象;拒绝重复字段、非对象、尾随内容、非法 UTF-8。数字保留为 `json.Number`。`BuildArguments` 拒绝重复/未知具名参数,body 完全覆盖已识别具名值,不合并、不注入 default。 +- `Parameters` 映射可明确表示的 string/integer/number/boolean/array/object 字段;保留名、组合、引用和无法表示的名字仅用 body 原名传入。校验明确的类型、required、enum、嵌套 properties/items;数值 enum 精确比较。其他 JSON Schema 关键字交由服务端,不访问远程 `$ref`。 +- 显式 `--context` 报 `unsupported_option`。不支持 `--header/--stage/--tenant`;`--timeout` 默认 60 秒,覆盖连接、全部分页和调用的总预算,不读取 context。取消或超时结束本次会话。离线 dry-run 必须带完整 body,拒绝具名工具参数,零网络并标记未验证远端。 -SDK 接入前提(2026-10-22 验证):官方主线提交 `12e768b461fd06ea80b0d87e14ac7fa0d24c606a` 的直接 JSON 解码仍将 Schema、tool/result/content 元数据中的 `9007199254740993` 改成 `9007199254740992`;同一工具结果的 structuredContent 保留原数值。上游 [#1239](https://github.com/modelcontextprotocol/go-sdk/pull/1239) 已合并,[#1311](https://github.com/modelcontextprotocol/go-sdk/pull/1311) 当时仍是未合并 Draft PR。依赖策略为只接受官方已合并提交,不采用第三方 PR head,也不降低数字保真验收要求。 +SDK 与数字保真(2026-10-22 验证): -后续需在修复合并后重新锁定 SDK 版本/提交,验证 Schema/元数据/参数/结果/jq 全链路数字精度及实际 `tools/call` 次数,再接入协议版本、响应上限、分页、超时、结果未知状态、命令与 mock 集成。当前本地单元测试不代表 MCP wire 兼容或真实服务联调通过。 +固定官方提交 [`12e768b461fd06ea80b0d87e14ac7fa0d24c606a`](https://github.com/modelcontextprotocol/go-sdk/commit/12e768b461fd06ea80b0d87e14ac7fa0d24c606a),Go module 为 `v1.8.1-0.20261022085357-12e768b461fd`,不依赖浮动分支或第三方 PR head。SDK 要求 Go 1.25.0,与仓库 Go 1.25.5 声明兼容;许可证处于 Apache-2.0/MIT 迁移,采用该提交的原始 LICENSE。新增依赖随 Go 二进制编译,不增加运行时服务;复用 SDK 引入的依赖,不另引入 JSON Schema 验证器。 + +上游 [#1239](https://github.com/modelcontextprotocol/go-sdk/pull/1239) 的 structuredContent 修复已合并,但 [#1311](https://github.com/modelcontextprotocol/go-sdk/pull/1311) 的 Schema/元数据修复在验证时仍未合并。所选提交直接解码这些泛型字段仍会把 `9007199254740993` 变成 `9007199254740992`。因此 `guardedTransport` 在 HTTP 读取边界以严格 JSON/`UseNumber` 保留原始目录和结果;SDK 仍负责版本协商、初始化、会话、JSON-RPC 路由和 SSE 消费。输出和参数构造使用保留的数据,不从 SDK 的 float64 还原数字。此适配也覆盖 outputSchema、content 元数据和 `_meta`;文本 content 不重新解析。简单 jq 字段提取保留数字,计算表达式沿用输出层 gojq 语义。正式版包含完整修复后须通过同一组测试再升级,不可仅因版本更新移除此边界。 + +生命周期和结果契约: + +- 测试覆盖 `2025-03-26`、`2025-06-18`、`2025-11-25`、`2026-07-28` 的 JSON/SSE 发现与调用。由 SDK 处理 `server/discover` 及旧协议初始化差异,不自行降级 transport。 +- 显式关闭 SDK 多轮输入、负载退避重发、独立 SSE 及重连;HTTP 请求移除 replay body,并统计/拒绝第二次 `tools/call`。发现和调用复用一次 client 生命周期,结束关闭会话和空闲连接。 +- 分页直到无 nextCursor,拒绝循环游标、重名工具、畸形目录;任何中途错误不得输出完整成功。空 tools 列表成功。目录不持久缓存。 +- `--check` 输出 config/connection/protocol/tools 阶段以及 `complete`;发现通过只表示该请求被接受,不证明 token 或所有业务权限有效。`--tools` 保留完整描述和元数据。 +- 本地输入/配置/参数错误走 stderr、退出 1,即使参数错误发生在发现之后。远程错误走一个 stdout 失败 envelope、退出 2;401/403 分别为 `mcp_auth_required`/`mcp_permission_denied`,不换身份或重试。 +- 调用保留 `data.server/tool/result`。`isError:true` 为 `mcp_tool_error`;input_required、sampling、elicitation 或 task 状态为 `mcp_capability_unsupported`,保留可用原始要求/标识,不接续、不伪报完成。 +- 调用已尝试但没有完整可判定结果时设 `data.execution_state: "unknown"`,保留实际错误码,提示先业务查询再决定重试。完整结果后的 jq/输出错误不改为 unknown,不重发。`ok:true` 不解释业务 JSON 的 code/result/status,不证明异步任务完成。 +- jq 在本地解析阶段编译,只对聚合/分类后的最终 envelope 执行一次;失败 envelope 不被投影隐藏,投影失败保留原结果。 + +固定资源上限: + +| 对象 | 上限与行为 | +| --- | --- | +| 配置/arguments JSON | 16 MiB;容器嵌套最多 128 层;错误为本地输入错误 | +| 单个展开后的 header | 16 MiB | +| 数值类型/enum 校验 | 字面量最多 4096 字符,显式指数绝对值最多 10000;超限明确拒绝,接受的值不截断 | +| JSON 响应(含 HTTP 错误体)/单条 SSE 事件 | 16 MiB,在读取过程中限制;`mcp_response_too_large` | +| 累计工具目录 | 原始分页 JSON 报文累计 32 MiB;`mcp_response_too_large` | +| 发现页数 | 最多 100 页;仍有 nextCursor 时 `mcp_discovery_incomplete` | + +远端 JSON 同样拒绝重复 key、非法 UTF-8 和超过 128 层的嵌套。限制不截断后返回成功;调用结果超限且未取得完整结果按 unknown 处理。上限容纳常规 Schema/结果并限制单次 CLI 的内存和发现成本,不提供调节 flags。 + +验收入口:`internal/mcpclient/*_test.go` 覆盖配置/认证/精度/协议/分页/大小限制/单次调用,`cmd/mcp/*_test.go` 与 `cmd/mcp_test.go` 覆盖模式、输出、帮助、context 隔离和 jq;`tests/integration/cases/mcp/` 的 MCP-001 至 MCP-005 覆盖构建后 CLI。真实环境验收清单见用户指南,mock 通过不代表真实服务已联调。 ### 5.9 `cmd/*` @@ -449,7 +477,7 @@ CLI 级错误输出到 stderr: ### 7.5 内置 `--jq` 结果投影 -`api`、YAML action、Go action 和 `cmdb list_biz_hosts_all` 支持独立的本地 `--jq ''`。该 flag 只在支持的执行命令注册,不是 root persistent flag;context、auth、doctor、version、update、skills 等命令显式使用时返回 unknown flag。帮助仍走原帮助路径,不执行请求。 +`api`、`mcp`、YAML action、Go action 和 `cmdb list_biz_hosts_all` 支持独立的本地 `--jq ''`。该 flag 只在支持的执行命令注册,不是 root persistent flag;context、auth、doctor、version、update、skills 等命令显式使用时返回 unknown flag。帮助仍走原帮助路径,不执行请求。 - 根输入为完整最终 envelope(包括 `.ok`、`.status`、`.headers`、`.data` 或 `.request`),聚合结束后执行一次。未传 flag 时保持原有输出与退出判定。 - 每个结果输出一行紧凑 JSON,字符串带引号并转义,多个结果形成 JSON Lines;`null` / `false` 不改变原命令退出码,`empty` 不输出字节。不提供外部 jq 的 `-r` / `-e` 或命令行选项。 diff --git a/docs/user-guide.md b/docs/user-guide.md index bd7f114..b539140 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -6,10 +6,11 @@ `bk-cli` 是一个和 BlueKing 平台 API 交互的命令行工具。 -你可以把它理解成两种用法: +你可以把它理解成三种用法: 1. 直接调用任意网关接口:`bk-cli api ...` 2. 使用已经封装好的高层命令:`bk-cli apigateway ...`、`bk-cli cmdb ...`、`bk-cli job ...` +3. 发现并调用远程 MCP 工具:`bk-cli mcp ...`,使用独立配置,不依赖 context/auth 登录 如果你是第一次接触,建议先记住这几个点: @@ -550,7 +551,7 @@ bk-cli api bk-apigateway GET /api/v2/open/gateways/ | jq . ### 内置 jq 筛选 -raw API 和所有 system action 可以直接用 `--jq` 查询完整最终 envelope: +raw API、MCP 和所有 system action 可以直接用 `--jq` 查询完整最终 envelope: ```bash # 提取网关名称,每个名称是一行带引号的 JSON 字符串 @@ -657,3 +658,103 @@ bk-cli api bk-apigateway GET /api/v2/open/gateways/{gateway_name}/resources/ \ - 项目简介与快速入口:[README.md](https://github.com/TencentBlueKing/bk-cli/blob/master/README.md) - 常见问题:[docs/faq.md](faq.md) - 开发者视角的扩展指南:[docs/develop-extension-guide.md](https://github.com/TencentBlueKing/bk-cli/blob/master/docs/develop-extension-guide.md) + +## 11. 远程 MCP 工具 + +`mcp` 直接连接远程 Streamable HTTP server。它不使用 `auth login` 保存的凭据,也不继承 context 的 URL、tenant、stage 或 timeout。切换 context 不会切换 MCP 身份;显式使用 `--context` 会报错。 + +### 配置与认证 + +默认读取 `~/.bk-cli/mcp.json`;设置 `BK_CLI_CONFIG_DIR` 后读取该目录的 `mcp.json`。使用 `--config ./mcp.json` 时只读取这个文件,不自动查找或合并其他客户端配置。文件必须为严格 JSON,URL 保持原样;一期只支持 `type: "http"`。 + +```json +{ + "mcpServers": { + "report-personal": { + "type": "http", + "url": "https://mcp.example.com/report/application/mcp/", + "headers": {"Authorization": "Bearer ${REPORT_PERSONAL_TOKEN}"} + }, + "report-app": { + "type": "http", + "url": "https://mcp.example.com/report/application/mcp/", + "headers": {"X-Bkapi-Authorization": "${REPORT_BK_AUTH}"} + }, + "public-docs": { + "type": "http", + "url": "https://mcp.example.com/public/mcp/" + } + } +} +``` + +只展开所选 server 的 header 中显式声明的 `${ENV_NAME}`。从安全的凭据来源设置这些环境变量;`REPORT_BK_AUTH` 应是完整 JSON 对象字符串,支持以下三种独立形态: + +```json +{"bk_app_code":"your_app","bk_app_secret":"your_secret","bk_ticket":"your_ticket"} +{"bk_app_code":"your_app","bk_app_secret":"your_secret","bk_token":"your_token"} +{"access_token":"your_access_token"} +``` + +不要同时配置 `Authorization` 与 `X-Bkapi-Authorization`。自定义 API key header 和第三方合法 Authorization scheme 也可透传。缺失环境变量、空认证值、重复配置字段或不支持的配置选项会明确失败,不自动回退匿名或其他身份。无认证 server 可以省略 headers。`$${` 表示字面 `${`;不会执行 shell、递归展开或读取 `.env`。 + +含真实票据的文件应仅允许当前用户读写,不提交版本库。配置和错误诊断中的全部 header 值都显示为 `***`;工具返回的业务数据原样保留,仍应按其敏感程度处理。 + +### 检查、发现、帮助与调用 + +```bash +# 本地帮助,不需要票据环境变量,不连接远端 +bk-cli mcp +bk-cli mcp report-personal + +# 检查协议和完整目录;查看完整工具定义 +bk-cli mcp report-personal --check +bk-cli mcp report-personal --tools +bk-cli mcp report-personal search_reports --help + +# 按远端 Schema 的原始字段名传参;布尔值显式赋值 +bk-cli mcp report-personal search_reports --keyword '网关错误' --limit 20 --enabled=false + +# 完整 arguments 对象,不是 JSON-RPC 报文;覆盖全部具名参数 +bk-cli mcp report-personal search_reports --body '{"keyword":"网关错误","limit":20}' +bk-cli mcp report-personal search_reports --body @params.json +bk-cli mcp report-personal search_reports --body - < params.json + +# 只用指定配置;提取最终结果 +bk-cli mcp --config ./mcp.json report-personal --tools --jq '.data.tools[].name' +bk-cli mcp report-personal search_reports --body @params.json --jq '.data.result' + +# 完全离线的预览;无参数工具也要显式提供 {} +bk-cli mcp report-personal search_reports --body @params.json --dry-run +``` + +示例 server/tool/字段名需换成实际配置和发现结果,名称大小写敏感,保留下划线、连字符和点号。工具帮助会联网获取 Schema,但不会调用工具或读取 body/stdin。`--check`、`--tools`、tool 调用互斥;不提供 `--header/--stage/--tenant`。复杂 Schema、无法映射的名字以及 `body/help/config` 等保留名字段使用完整 body,不自动改名。具名数组和对象使用 JSON,不能用逗号分隔代替。只发送显式值,不注入 Schema default。body 顶层必须是对象,无参数用 `{}`。 + +`--timeout 90s` 设置本次连接、所有发现页和工具调用的总时间,默认 60 秒。`--verbose` 在 stderr 输出已脱敏的诊断;`--insecure` 仅用于明确需要跳过证书验证的环境。重定向不会被跟随,应直接配置最终 endpoint。 + +### 判断结果与排错 + +| 情况 | 输出与下一步 | +| --- | --- | +| 本地配置/参数错误 | stderr 结构化错误,退出 1;修正配置或参数,工具未调用 | +| check/tools 成功 | stdout `ok:true`、`complete:true`;只证明本次连接/发现成功,不证明所有业务权限 | +| 同步工具成功 | stdout `data.result`,退出 0;仍按工具业务契约判断数据,任务 ID 不代表任务已完成 | +| 401 / 403 | 退出 2;分别检查所选 headers 的认证和该身份的服务/工具/业务权限,不会自动登录或切换凭据 | +| 工具 `isError:true` | 退出 2,保留原始结果;检查 `data.result` | +| `mcp_capability_unsupported` | 退出 2,保留输入要求或 task 标识;不自动补充输入、轮询或再次调用 | +| `data.execution_state: "unknown"` | 工具请求可能已执行,但完整结果丢失;先通过业务查询核实,不能直接重跑写操作 | +| jq 运行或输出错误 | 退出 2,尽可能保留完整原结果;展示失败不表示调用未执行,不自动重发 | + +每次 CLI 执行最多发送一次 `tools/call`。一期不支持 OAuth 自动登录、stdio、旧 HTTP+SSE、sampling/elicitation、input_required 接续或异步 task 轮询。HTTP 200 不单独代表成功;失败 envelope 不被 jq 隐藏。默认结果及简单 jq 字段提取保留收到的 JSON 数字精度,涉及计算的 jq 表达式仍遵循 gojq 数值语义。 + +固定限制为单个 JSON 输入/响应或 SSE 事件 16 MiB,累计发现目录 32 MiB,最多 100 页,JSON 最多嵌套 128 层。超过响应/目录上限报 `mcp_response_too_large`,超过分页上限报 `mcp_discovery_incomplete`,不会静默截断。离线 dry-run 只能校验配置、认证格式和 body 结构,不能证明远端工具存在、Schema 合法、票据有效或业务权限足够。 + +### 人工真实环境验收清单 + +以下清单需在有授权的测试环境手动执行。仓库 mock 测试不证明真实服务联调通过,不要求把真实票据写入测试文件。 + +1. 为同一个可控 server 分别配置个人 Bearer、应用 + ticket、应用 + token、access_token,以及可用的匿名 server;验证 check、完整 tools 和一个明确无副作用的工具。 +2. 切换或移除 active context,确认 MCP 仍使用所选配置;换成失效票据及权限不足身份,确认 401/403 分类,检查 verbose 无票据泄漏。 +3. 根据服务端日志核对初始化、发现分页、调用使用同一身份;选择包含复杂参数、大整数和元数据的工具,核对实际 arguments、结果及 jq 字段值。 +4. 在测试服务注入延迟、断流、input_required 和异步 task 响应,确认退出码、unknown/未完成状态,且每次 CLI 最多一次 tools/call;不要在生产写工具上制造超时。 +5. 检查 JSON/SSE、会话释放、证书和最终 endpoint;记录服务版本、CLI 提交、已验证协议版本及未验证项,避免把一次发现成功当成全功能验收。 diff --git a/go.mod b/go.mod index 6ae54b6..23906af 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.25.5 require ( github.com/goccy/go-json v0.10.6 github.com/itchyny/gojq v0.12.19 + github.com/modelcontextprotocol/go-sdk v1.8.1-0.20261022085357-12e768b461fd github.com/onsi/ginkgo/v2 v2.31.0 github.com/onsi/gomega v1.42.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/mod v0.37.0 golang.org/x/oauth2 v0.36.0 @@ -19,13 +21,17 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect - github.com/spf13/pflag v1.0.10 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index ca6ec69..f5c1002 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,12 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -37,6 +41,8 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/modelcontextprotocol/go-sdk v1.8.1-0.20261022085357-12e768b461fd h1:XZ81VIUur6LAWHfO28w7dqjZgKb/iZwEPcFQcIk7dl8= +github.com/modelcontextprotocol/go-sdk v1.8.1-0.20261022085357-12e768b461fd/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/onsi/ginkgo/v2 v2.31.0 h1:GtuJos5DFUV9EerYJo8RhYxosYNGvOdDE5haKq6Grfs= github.com/onsi/ginkgo/v2 v2.31.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.0 h1:CJby8u36xb7v34W78F8WKvqTQP7PCMIPB78IVDB73l4= @@ -46,6 +52,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -61,6 +71,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +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= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= @@ -77,6 +89,8 @@ golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go new file mode 100644 index 0000000..adaf0b6 --- /dev/null +++ b/internal/mcpclient/client.go @@ -0,0 +1,245 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "context" + "crypto/tls" + "errors" + "io" + "log/slog" + "maps" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Client owns one CLI operation's SDK session, deadlines and raw result capture. +type Client struct { + ctx context.Context + endpoint string + http *http.Client + transport *guardedTransport + state *wireState + session *mcp.ClientSession +} + +// NewClient creates an isolated client. Headers must already be resolved and +// validated; no context credentials, OAuth handlers or retry middleware are used. +func NewClient(ctx context.Context, endpoint string, headers http.Header, insecure bool) *Client { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + base = &http.Transport{Proxy: http.ProxyFromEnvironment} + } + base = base.Clone() + base.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: insecure, + } // #nosec G402 -- explicit CLI flag. + state := &wireState{} + transport := &guardedTransport{base: base, ctx: ctx, headers: headers.Clone(), state: state} + return &Client{ctx: ctx, endpoint: endpoint, state: state, transport: transport, http: &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }} +} + +// Connect performs SDK-owned negotiation without connecting any other server. +func (c *Client) Connect() error { + sdk := mcp.NewClient(&mcp.Implementation{Name: "bk-cli", Version: "1"}, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{}, MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + session, err := sdk.Connect(c.ctx, &mcp.StreamableClientTransport{ + Endpoint: c.endpoint, + HTTPClient: c.http, + MaxRetries: -1, + DisableStandaloneSSE: true, + MaxEventSize: MaxResponseBytes, + }, nil) + if err != nil { + return c.classify(err) + } + c.session = session + return nil +} + +// Close releases the logical session and idle connections. Cleanup is bounded +// by the operation context at the HTTP boundary, including SDK DELETE requests. +func (c *Client) Close() { + if c.session != nil { + _ = c.session.Close() + } + c.transport.base.CloseIdleConnections() +} + +// Connected reports whether any target HTTP response was received. +func (c *Client) Connected() bool { + c.state.mu.Lock() + defer c.state.mu.Unlock() + return c.state.reached +} + +// ProtocolVersion returns the SDK-negotiated version after successful Connect. +func (c *Client) ProtocolVersion() string { + if c.session == nil { + return "" + } + return c.session.InitializeResult().ProtocolVersion +} + +// Headers returns the last response headers with configured/known secrets masked. +func (c *Client) Headers() map[string]string { + c.state.mu.Lock() + defer c.state.mu.Unlock() + return maps.Clone(c.state.headers) +} + +// CallAttempted reports whether a tools/call entered the HTTP transport. +func (c *Client) CallAttempted() bool { + c.state.mu.Lock() + defer c.state.mu.Unlock() + return c.state.calls > 0 +} + +// ResultKnown distinguishes explicit protocol/tool outcomes from lost responses. +func (c *Client) ResultKnown() bool { + c.state.mu.Lock() + defer c.state.mu.Unlock() + return c.state.known +} + +func (c *Client) classify(err error) error { + if c.ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return remoteError("mcp_timeout") + } + c.state.mu.Lock() + defer c.state.mu.Unlock() + if c.state.failure != nil { + return c.state.failure + } + return remoteError("mcp_protocol_error") +} + +func (c *Client) result() (map[string]any, int) { + c.state.mu.Lock() + defer c.state.mu.Unlock() + return c.state.result, c.state.resultBytes +} + +// Discover consumes every page, rejects conflicts/cycles, and returns the exact +// losslessly decoded descriptors, including SDK-supported optional metadata. +func (c *Client) Discover() ([]map[string]any, error) { + tools := make([]map[string]any, 0) + names := make(map[string]bool) + cursors := make(map[string]bool) + cursor := "" + total := 0 + for range MaxDiscoveryPages { + c.state.begin() + _, err := c.session.ListTools(c.ctx, &mcp.ListToolsParams{Cursor: cursor}) + if err != nil { + return nil, c.classify(err) + } + raw, size := c.result() + total += size + if total > MaxCatalogBytes { + return nil, remoteError("mcp_response_too_large") + } + list, ok := raw["tools"].([]any) + if !ok { + return nil, remoteError("mcp_protocol_error") + } + for _, entry := range list { + tool, ok := entry.(map[string]any) + if !ok { + return nil, remoteError("mcp_protocol_error") + } + name, ok := tool["name"].(string) + if !ok || name == "" || names[name] { + return nil, remoteError("mcp_protocol_error") + } + if _, ok := tool["inputSchema"].(map[string]any); !ok { + return nil, remoteError("mcp_protocol_error") + } + names[name] = true + tools = append(tools, tool) + } + next, exists := raw["nextCursor"] + if !exists || next == "" { + return tools, nil + } + cursor, ok = next.(string) + if !ok { + return nil, remoteError("mcp_protocol_error") + } + if cursors[cursor] { + return nil, remoteError("mcp_discovery_incomplete") + } + cursors[cursor] = true + } + return nil, remoteError("mcp_discovery_incomplete") +} + +// Call sends at most one tools/call. A non-nil result is preserved for explicit +// tool errors or unsupported asynchronous/input-required outcomes. +func (c *Client) Call(name string, arguments map[string]any) (map[string]any, error) { + c.state.begin() + _, err := c.session.CallTool(c.ctx, &mcp.CallToolParams{Name: name, Arguments: arguments}) + result, _ := c.result() + if err != nil { + return result, c.classify(err) + } + if result == nil { + return nil, remoteError("mcp_protocol_error") + } + if unsupportedResult(result) { + c.markKnown() + return result, remoteError("mcp_capability_unsupported") + } + if _, ok := result["content"].([]any); !ok { + return nil, remoteError("mcp_protocol_error") + } + if value, exists := result["isError"]; exists { + failed, ok := value.(bool) + if !ok { + return nil, remoteError("mcp_protocol_error") + } + if failed { + c.markKnown() + return result, remoteError("mcp_tool_error") + } + } + c.markKnown() + return result, nil +} + +func (c *Client) markKnown() { c.state.mu.Lock(); c.state.known = true; c.state.mu.Unlock() } + +func unsupportedResult(result map[string]any) bool { + if kind, ok := result["resultType"].(string); ok && kind != "" && kind != "complete" { + return true + } + for _, key := range []string{"task", "taskId", "inputRequests", "unsupported_request"} { + if _, ok := result[key]; ok { + return true + } + } + return false +} diff --git a/internal/mcpclient/client_edges_test.go b/internal/mcpclient/client_edges_test.go new file mode 100644 index 0000000..a631cc4 --- /dev/null +++ b/internal/mcpclient/client_edges_test.go @@ -0,0 +1,342 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MCP remote boundaries", func() { + open := func(f *protocolFixture) *Client { + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + DeferCleanup(cancel) + client := NewClient(ctx, server.URL, nil, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(Succeed()) + return client + } + DescribeTable( + "preserves explicit failure state", + func(raw, code string, known bool) { + f := fixture() + f.result = raw + client := open(f) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + result, err := client.Call("echo", map[string]any{}) + Expect(err).To(MatchError(ContainSubstring(code))) + Expect(client.ResultKnown()).To(Equal(known)) + Expect(client.CallAttempted()).To(BeTrue()) + Expect(f.calls).To(Equal(1)) + if known { + Expect(result).NotTo(BeNil()) + } + }, + Entry("tool error", `{"content":[],"isError":true}`, "mcp_tool_error", true), + Entry("task", `{"task":{"taskId":"pending"}}`, "mcp_capability_unsupported", true), + Entry( + "empty input", + `{"resultType":"input_required","inputRequests":{}}`, + "mcp_capability_unsupported", + true, + ), + Entry( + "missing content", + `{}`, + "mcp_protocol_error", + false, + ), + Entry("wrong flag type", `{"content":[],"isError":"false"}`, "mcp_protocol_error", false), + Entry( + "non-object", + `[]`, + "mcp_protocol_error", + false, + ), + Entry("malformed", `{`, "mcp_protocol_error", false), + ) + It("keeps complete JSON-RPC errors distinct from unknown tool outcomes", func() { + f := fixture() + f.rpcError = true + client := open(f) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + _, err = client.Call("echo", nil) + Expect(err).To(MatchError(ContainSubstring("mcp_protocol_error"))) + Expect(client.ResultKnown()).To(BeTrue()) + Expect(err.Error()).NotTo(ContainSubstring("secret-error")) + Expect(f.calls).To(Equal(1)) + }) + DescribeTable("keeps malformed JSON-RPC errors unknown", func(raw string) { + f := fixture() + f.rawError = raw + client := open(f) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + _, err = client.Call("echo", nil) + Expect(err).To(MatchError(ContainSubstring("mcp_protocol_error"))) + Expect(client.ResultKnown()).To(BeFalse()) + Expect(f.calls).To(Equal(1)) + }, Entry("null", `null`), Entry("empty object", `{}`), + Entry("missing code", `{"message":"bad"}`), + Entry("fractional code", `{"code":1.5,"message":"bad"}`), + Entry("missing message", `{"code":-32603}`)) + It("keeps the first SSE response selected by the SDK", func() { + f := fixture() + f.sse = true + f.duplicateResult = true + client := open(f) + tools, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + Expect(tools[0]["_meta"]).To(Equal(map[string]any{"n": json.Number("9007199254740993")})) + result, err := client.Call("echo", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(result["structuredContent"]).To(Equal(map[string]any{"n": json.Number("9007199254740993")})) + Expect(f.calls).To(Equal(1)) + }) + DescribeTable("ignores non-message and empty SSE events exactly as the SDK does", func(prefix string) { + f := fixture() + f.sse = true + f.eventPrefix = prefix + client := open(f) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + result, err := client.Call("echo", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(result["structuredContent"]).To(Equal(map[string]any{"n": json.Number("9007199254740993")})) + Expect(f.calls).To(Equal(1)) + }, Entry("named event", "event: ping\ndata: heartbeat\n\n"), + Entry("empty data priming event", "id: initial\ndata:\n\n")) + DescribeTable("never repeats a tool for HTTP errors", func(status int, code string) { + f := fixture() + f.callStatus = status + client := open(f) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + _, err = client.Call("echo", nil) + Expect(err).To(MatchError(ContainSubstring(code))) + Expect(f.calls).To(Equal(1)) + }, Entry("unauthorized", 401, "mcp_auth_required"), Entry("forbidden", 403, "mcp_permission_denied"), Entry("load shedding", 503, "mcp_protocol_error")) + It("reports unsupported sampling without interactive handling or resending", func() { + f := fixture() + f.prefix = "data: {\"jsonrpc\":\"2.0\",\"id\":\"server-1\",\"method\":\"sampling/createMessage\",\"params\":{}}\n\n" + client := open(f) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + result, err := client.Call("echo", nil) + Expect(err).To(MatchError(ContainSubstring("mcp_capability_unsupported"))) + Expect(result).To(HaveKey("unsupported_request")) + Expect(f.calls).To(Equal(1)) + }) + It("does not resend after a disconnected SSE result stream", func() { + f := fixture() + f.prefix = ": keepalive\n\n" + client := open(f) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + _, err = client.Call("echo", nil) + Expect(err).To(HaveOccurred()) + Expect(client.ResultKnown()).To(BeFalse()) + Expect(f.calls).To(Equal(1)) + }) + It("aggregates all pages including exact schema, content and result metadata", func() { + f := fixture() + f.listFn = func(page int) string { + if page == 1 { + return strings.TrimSuffix(f.list, "}") + `,"nextCursor":"next"}` + } + return `{"tools":[]}` + } + client := open(f) + tools, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + Expect(f.listCount).To(Equal(2)) + Expect(tools).To(HaveLen(1)) + encoded, err := json.Marshal(tools) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).To(ContainSubstring(`"enum":[9007199254740993]`)) + result, err := client.Call("echo", nil) + Expect(err).NotTo(HaveOccurred()) + encoded, err = json.Marshal(result) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).To(ContainSubstring(`"_meta":{"n":9007199254740993}`)) + Expect(string(encoded)).To(ContainSubstring("0.1234567890123456789")) + Expect(client.Connected()).To(BeTrue()) + Expect(client.ProtocolVersion()).To(Equal("2025-11-25")) + Expect(client.Headers()).To(HaveKey("Content-Type")) + }) + It("stops at the page limit and accepts exactly the limit when complete", func() { + for _, complete := range []bool{false, true} { + f := fixture() + f.listFn = func(page int) string { + if complete && page == MaxDiscoveryPages { + return `{"tools":[]}` + } + return fmt.Sprintf(`{"tools":[],"nextCursor":"%d"}`, page) + } + client := open(f) + tools, err := client.Discover() + if complete { + Expect(err).NotTo(HaveOccurred()) + Expect(tools).To(BeEmpty()) + } else { + Expect(err).To(MatchError(ContainSubstring("mcp_discovery_incomplete"))) + } + Expect(f.listCount).To(Equal(MaxDiscoveryPages)) + Expect(f.calls).To(BeZero()) + } + }) + It("bounds the accumulated tool catalog before any tool call", func() { + f := fixture() + description := strings.Repeat("x", 12<<20) + f.listFn = func(page int) string { + return fmt.Sprintf( + `{"tools":[{"name":"t%d","description":%q,"inputSchema":{}}],"nextCursor":"%d"}`, + page, + description, + page, + ) + } + client := open(f) + _, err := client.Discover() + Expect(err).To(MatchError(ContainSubstring("mcp_response_too_large"))) + Expect(f.listCount).To(Equal(3)) + Expect(f.calls).To(BeZero()) + }) + DescribeTable("rejects invalid tool directories", func(list string) { + f := fixture() + f.list = list + client := open(f) + _, err := client.Discover() + Expect(err).To(HaveOccurred()) + Expect(f.calls).To(BeZero()) + }, Entry("missing list", `{}`), Entry("bad tool", `{"tools":[null]}`), Entry("no schema", `{"tools":[{"name":"x"}]}`), Entry("bad cursor", `{"tools":[],"nextCursor":4}`)) + It("uses a total deadline and releases a timed out request", func() { + f := fixture() + f.delay = time.Second + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + DeferCleanup(cancel) + client := NewClient(ctx, server.URL, nil, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(Succeed()) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + _, err = client.Call("echo", nil) + Expect(err).To(MatchError(ContainSubstring("mcp_timeout"))) + Expect(client.ResultKnown()).To(BeFalse()) + f.mu.Lock() + defer f.mu.Unlock() + Expect(f.calls).To(Equal(1)) + }) + It("blocks redirects without forwarding any credentials", func() { + reached := false + target := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }), + ) + DeferCleanup(target.Close) + server := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 302) }, + ), + ) + DeferCleanup(server.Close) + client := NewClient(context.Background(), server.URL, http.Header{"X-Key": []string{"secret"}}, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(MatchError(ContainSubstring("mcp_connection_error"))) + Expect(reached).To(BeFalse()) + }) + It("requires explicit insecure for an untrusted TLS server", func() { + f := fixture() + server := httptest.NewTLSServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + for _, insecure := range []bool{false, true} { + client := NewClient(context.Background(), server.URL, nil, insecure) + DeferCleanup(client.Close) + err := client.Connect() + if insecure { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(MatchError(ContainSubstring("mcp_connection_error"))) + } + } + }) + It("masks configured and known response credentials case-insensitively", func() { + masked := maskedResponseHeaders( + http.Header{ + "x-api-key": []string{"secret"}, + "Authorization": []string{"Bearer secret"}, + "X-Bkapi-Authorization": []string{"secret"}, + "Set-Cookie": []string{"secret"}, + "Content-Type": []string{"application/json"}, + }, + http.Header{"X-Api-Key": []string{""}}, + ) + Expect(masked["x-api-key"]).To(Equal("***")) + Expect(masked["Authorization"]).To(Equal("***")) + Expect(masked["X-Bkapi-Authorization"]).To(Equal("***")) + Expect(masked["Set-Cookie"]).To(Equal("***")) + Expect(masked["Content-Type"]).To(Equal("application/json")) + }) + It("enforces exact JSON and SSE read limits without success truncation", func() { + for _, sse := range []bool{false, true} { + for _, extra := range []int{0, 1} { + state := &wireState{} + prefix := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + if sse { + prefix = "data: " + prefix + prefix += strings.Repeat(" ", MaxResponseBytes-len(prefix)-2) + "\n\n" + } else { + prefix += strings.Repeat(" ", MaxResponseBytes-len(prefix)) + } + if extra > 0 { + prefix = " " + prefix + } + body := &observedBody{ + source: io.NopCloser(bytes.NewBufferString(prefix)), + state: state, + method: "tools/list", + id: json.RawMessage("1"), + sse: sse, + } + _, err := io.Copy(io.Discard, body) + Expect(body.Close()).To(Succeed()) + if extra == 0 { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(MatchError(ContainSubstring("mcp_response_too_large"))) + } + } + } + }) +}) diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go new file mode 100644 index 0000000..2495181 --- /dev/null +++ b/internal/mcpclient/client_test.go @@ -0,0 +1,317 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type protocolFixture struct { + mu sync.Mutex + methods []string + calls int + version string + sse bool + list string + result string + status int + delay time.Duration + headers []http.Header + arguments json.RawMessage + listFn func(int) string + listCount int + callStatus int + prefix string + rpcError bool + rawError string + eventPrefix string + duplicateResult bool +} + +func (f *protocolFixture) serve(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + f.mu.Lock() + f.methods = append(f.methods, "DELETE") + f.headers = append(f.headers, r.Header.Clone()) + f.mu.Unlock() + w.WriteHeader(204) + return + } + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Arguments json.RawMessage `json:"arguments"` + } `json:"params"` + } + if json.NewDecoder(r.Body).Decode(&request) != nil { + w.WriteHeader(400) + return + } + f.mu.Lock() + f.methods = append(f.methods, request.Method) + f.headers = append(f.headers, r.Header.Clone()) + if request.Method == "tools/call" { + f.calls++ + f.arguments = request.Params.Arguments + } + f.mu.Unlock() + if f.status != 0 { + w.WriteHeader(f.status) + return + } + w.Header().Set("Content-Type", "application/json") + var result string + switch request.Method { + case "server/discover": + if f.version != "2026-07-28" { + fmt.Fprintf( + w, + `{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"unsupported"}}`, + request.ID, + ) + return + } + result = `{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{}},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"fixture","version":"1"}}}` + case "initialize": + w.Header().Set("Mcp-Session-Id", "fixture-session") + result = fmt.Sprintf( + `{"protocolVersion":%q,"capabilities":{"tools":{}},"serverInfo":{"name":"fixture","version":"1"}}`, + f.version, + ) + case "notifications/initialized", "notifications/cancelled": + w.WriteHeader(202) + return + case "tools/list": + f.listCount++ + result = f.list + if f.listFn != nil { + result = f.listFn(f.listCount) + } + case "tools/call": + if f.callStatus > 0 { + w.WriteHeader(f.callStatus) + return + } + if f.delay > 0 { + select { + case <-time.After(f.delay): + case <-r.Context().Done(): + return + } + } + result = f.result + default: + w.WriteHeader(400) + return + } + message := fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, request.ID, result) + if f.rpcError && request.Method == "tools/call" { + message = fmt.Sprintf( + `{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"secret-error"}}`, + request.ID, + ) + } + if f.rawError != "" && request.Method == "tools/call" { + message = fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"error":%s}`, request.ID, f.rawError) + } + if f.prefix != "" && request.Method == "tools/call" { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, f.prefix) + return + } + if f.sse { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "%sevent: message\r\ndata: %s\r\n\r\n", f.eventPrefix, message) + if f.duplicateResult { + fmt.Fprintf( + w, + "event: message\ndata: %s\n\n", + strings.ReplaceAll(message, "9007199254740993", "42"), + ) + } + } else { + fmt.Fprint(w, message) + } +} + +func fixture() *protocolFixture { + return &protocolFixture{ + version: "2025-11-25", + list: `{"tools":[{"name":"echo","inputSchema":{"type":"object","properties":{"n":{"type":"integer","enum":[9007199254740993]}}},"_meta":{"n":9007199254740993}}]}`, + result: `{"content":[{"type":"text","text":"ok","_meta":{"n":9007199254740993}}],"structuredContent":{"n":9007199254740993},"_meta":{"decimal":0.1234567890123456789}}`, + } +} + +var _ = Describe("MCP protocol client", func() { + DescribeTable("negotiates and preserves raw values with exactly one tool call", func(version string, sse bool) { + f := fixture() + f.version = version + f.sse = sse + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + DeferCleanup(cancel) + client := NewClient(ctx, server.URL, http.Header{"Authorization": []string{"Bearer fixture"}}, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(Succeed()) + tools, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + Expect(tools).To(HaveLen(1)) + Expect(tools[0]["_meta"]).To(Equal(map[string]any{"n": json.Number("9007199254740993")})) + result, err := client.Call("echo", map[string]any{"n": json.Number("9007199254740993")}) + Expect(err).NotTo(HaveOccurred()) + Expect(result["_meta"]).To(Equal(map[string]any{"decimal": json.Number("0.1234567890123456789")})) + f.mu.Lock() + defer f.mu.Unlock() + Expect(f.calls).To(Equal(1)) + Expect(string(f.arguments)).To(ContainSubstring("9007199254740993")) + for _, headers := range f.headers { + Expect(headers.Get("Authorization")).To(Equal("Bearer fixture")) + } + }, Entry("2025 March JSON", "2025-03-26", false), Entry("2025 June SSE", "2025-06-18", true), Entry("2025 November JSON", "2025-11-25", false), Entry("2026 July SSE", "2026-07-28", true)) + DescribeTable( + "uses only the selected identity for initialization, every page, call and cleanup", + func(name, value string) { + f := fixture() + f.listFn = func(page int) string { + if page == 1 { + return `{"tools":[],"nextCursor":"second"}` + } + return f.list + } + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + DeferCleanup(cancel) + headers := make(http.Header) + if name != "" { + headers.Set(name, value) + } + client := NewClient(ctx, server.URL, headers, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(Succeed()) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + _, err = client.Call("echo", nil) + Expect(err).NotTo(HaveOccurred()) + client.Close() + f.mu.Lock() + defer f.mu.Unlock() + Expect(f.methods).To(ContainElements("initialize", "tools/list", "tools/call", "DELETE")) + Expect(f.listCount).To(Equal(2)) + Expect(f.calls).To(Equal(1)) + for _, actual := range f.headers { + Expect(actual.Get("Authorization")).To(Equal(headers.Get("Authorization"))) + Expect( + actual.Get("X-Bkapi-Authorization"), + ).To( + Equal(headers.Get("X-Bkapi-Authorization")), + ) + Expect(actual.Get("X-Bk-Tenant-Id")).To(BeEmpty()) + } + }, + Entry("personal", "Authorization", "Bearer personal"), + Entry( + "application ticket", + "X-Bkapi-Authorization", + `{"bk_app_code":"app","bk_app_secret":"secret","bk_ticket":"ticket"}`, + ), + Entry( + "application token", + "X-Bkapi-Authorization", + `{"bk_app_code":"app","bk_app_secret":"secret","bk_token":"token"}`, + ), + Entry("access token", "X-Bkapi-Authorization", `{"access_token":"access"}`), + Entry("anonymous", "", ""), + ) + It("does not retry authentication failures", func() { + f := fixture() + f.status = 401 + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + client := NewClient(context.Background(), server.URL, nil, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(MatchError(ContainSubstring("mcp_auth_required"))) + Expect(f.methods).To(HaveLen(1)) + }) + It("rejects duplicate tools and never calls a tool", func() { + f := fixture() + f.list = `{"tools":[{"name":"x","inputSchema":{}},{"name":"x","inputSchema":{}}]}` + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + client := NewClient(context.Background(), server.URL, nil, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(Succeed()) + _, err := client.Discover() + Expect(err).To(HaveOccurred()) + Expect(f.calls).To(BeZero()) + }) + It("detects repeated discovery cursors", func() { + f := fixture() + f.list = `{"tools":[],"nextCursor":"same"}` + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + client := NewClient(context.Background(), server.URL, nil, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(Succeed()) + _, err := client.Discover() + Expect(err).To(MatchError(ContainSubstring("mcp_discovery_incomplete"))) + }) + It("classifies input_required including empty requests without continuation", func() { + f := fixture() + f.result = `{"resultType":"input_required","inputRequests":{},"requestState":"state"}` + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + client := NewClient(context.Background(), server.URL, nil, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(Succeed()) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + result, err := client.Call("echo", map[string]any{}) + Expect(err).To(MatchError(ContainSubstring("mcp_capability_unsupported"))) + Expect(result).To(HaveKeyWithValue("requestState", "state")) + Expect(f.calls).To(Equal(1)) + }) + It("bounds malformed and oversized responses", func() { + f := fixture() + f.result = `{"content":[{"type":"text","text":"` + strings.Repeat("x", MaxResponseBytes) + `"}]}` + server := httptest.NewServer(http.HandlerFunc(f.serve)) + DeferCleanup(server.Close) + client := NewClient(context.Background(), server.URL, nil, false) + DeferCleanup(client.Close) + Expect(client.Connect()).To(Succeed()) + _, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + _, err = client.Call("echo", map[string]any{}) + Expect(err).To(MatchError(ContainSubstring("mcp_response_too_large"))) + Expect(client.CallAttempted()).To(BeTrue()) + Expect(f.calls).To(Equal(1)) + }) +}) diff --git a/internal/mcpclient/transport.go b/internal/mcpclient/transport.go new file mode 100644 index 0000000..5277921 --- /dev/null +++ b/internal/mcpclient/transport.go @@ -0,0 +1,378 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions and + * limitations under the License. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package mcpclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "sync" +) + +// Fixed remote limits apply while reading, before SDK decoding or aggregation. +const ( + MaxResponseBytes = 16 << 20 + MaxCatalogBytes = 32 << 20 + MaxDiscoveryPages = 100 +) + +type wireState struct { + mu sync.Mutex + failure *Error + result map[string]any + resultBytes int + known bool + reached bool + calls int + headers map[string]string +} + +func remoteError(code string) *Error { + messages := map[string]string{ + "mcp_auth_required": "MCP authentication is missing or rejected; check the selected server headers", + "mcp_permission_denied": "MCP permission denied; check server, tool and business permissions", + "mcp_connection_error": "MCP connection failed; check the configured endpoint and TLS settings", + "mcp_timeout": "MCP operation timed out or was cancelled", + "mcp_protocol_error": "Invalid MCP response or protocol negotiation", + "mcp_discovery_incomplete": "MCP tool discovery did not complete", + "mcp_response_too_large": "MCP response or tool catalog exceeds the fixed size limit", + "mcp_tool_error": "The MCP tool reported an error", + "mcp_capability_unsupported": "MCP interaction or asynchronous execution is not supported", + } + return &Error{code, messages[code]} +} + +func (s *wireState) fail(code string) *Error { + s.mu.Lock() + defer s.mu.Unlock() + if s.failure == nil || code == "mcp_response_too_large" { + s.failure = remoteError(code) + } + return s.failure +} + +func (s *wireState) begin() { + s.mu.Lock() + defer s.mu.Unlock() + s.failure = nil + s.result = nil + s.resultBytes = 0 + s.known = false +} + +// observe retains lossless JSON before the SDK converts generic metadata to +// float64. It does not implement protocol negotiation, routing or retries. +func (s *wireState) observe(data []byte, method string, id json.RawMessage) (bool, error) { + raw, err := decodeJSON(bytes.NewReader(data)) + if err != nil { + return true, s.fail("mcp_protocol_error") + } + object, ok := raw.(map[string]any) + if !ok || object["jsonrpc"] != "2.0" { + return true, s.fail("mcp_protocol_error") + } + if incoming, ok := object["method"].(string); ok { + if incoming == "sampling/createMessage" || incoming == "elicitation/create" || + strings.HasPrefix(incoming, "tasks/") { + s.mu.Lock() + s.result = map[string]any{"unsupported_request": object} + s.known = true + s.mu.Unlock() + return true, s.fail("mcp_capability_unsupported") + } + return false, nil + } + var identity struct { + ID json.RawMessage `json:"id"` + } + if json.Unmarshal(data, &identity) != nil || !bytes.Equal(bytes.TrimSpace(identity.ID), bytes.TrimSpace(id)) { + return true, s.fail("mcp_protocol_error") + } + if method != "tools/list" && method != "tools/call" { + return true, nil + } + if _, exists := object["error"]; exists { + if !validRPCError(object) { + return true, s.fail("mcp_protocol_error") + } + s.mu.Lock() + s.known = true + s.mu.Unlock() + return true, s.fail("mcp_protocol_error") + } + result, ok := object["result"].(map[string]any) + if !ok { + return true, s.fail("mcp_protocol_error") + } + s.mu.Lock() + s.result = result + s.resultBytes = len(data) + s.mu.Unlock() + return true, nil +} + +func validRPCError(object map[string]any) bool { + if object["jsonrpc"] != "2.0" { + return false + } + if _, exists := object["result"]; exists { + return false + } + detail, ok := object["error"].(map[string]any) + if !ok { + return false + } + code, ok := detail["code"].(json.Number) + if !ok { + return false + } + if _, err := strconv.ParseInt(string(code), 10, 64); err != nil { + return false + } + _, ok = detail["message"].(string) + return ok +} + +type guardedTransport struct { + base *http.Transport + ctx context.Context + headers http.Header + state *wireState +} + +// RoundTrip applies one target's headers and enforces no-replay and read limits. +func (t *guardedTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if t.ctx.Err() != nil { + return nil, t.state.fail("mcp_timeout") + } + t.state.mu.Lock() + failure := t.state.failure + t.state.mu.Unlock() + if failure != nil { + return nil, failure + } + var rpc struct { + Method string `json:"method"` + ID json.RawMessage `json:"id"` + } + if request.GetBody != nil { + body, err := request.GetBody() + if err != nil { + return nil, t.state.fail("mcp_protocol_error") + } + err = json.NewDecoder(body).Decode(&rpc) + _ = body.Close() + if err != nil { + return nil, t.state.fail("mcp_protocol_error") + } + } + if rpc.Method == "tools/call" { + t.state.mu.Lock() + t.state.calls++ + calls := t.state.calls + t.state.mu.Unlock() + if calls > 1 { + return nil, t.state.fail("mcp_protocol_error") + } + } + requestContext, cancel := context.WithCancel(request.Context()) + stop := context.AfterFunc(t.ctx, cancel) + release := func() { stop(); cancel() } + cloned := request.Clone(requestContext) + for key, values := range t.headers { + cloned.Header[key] = append([]string(nil), values...) + } + // Do not give net/http a replay function for mutating MCP POST requests. + cloned.GetBody = nil + response, err := t.base.RoundTrip(cloned) + if err != nil { + release() + if request.Context().Err() != nil || t.ctx.Err() != nil { + return nil, t.state.fail("mcp_timeout") + } + return nil, t.state.fail("mcp_connection_error") + } + t.state.mu.Lock() + t.state.reached = true + t.state.headers = maskedResponseHeaders(response.Header, t.headers) + t.state.mu.Unlock() + if response.StatusCode < 200 || response.StatusCode >= 300 { + data, readErr := io.ReadAll(io.LimitReader(response.Body, MaxResponseBytes+1)) + _ = response.Body.Close() + release() + if len(data) > MaxResponseBytes { + return nil, t.state.fail("mcp_response_too_large") + } + if readErr != nil { + return nil, t.state.fail("mcp_connection_error") + } + // The SDK owns the specified server/discover -> initialize compatibility fallback. + if rpc.Method == "server/discover" && + (response.StatusCode == http.StatusBadRequest || response.StatusCode == http.StatusNotFound || + response.StatusCode == http.StatusMethodNotAllowed) { + response.Body = io.NopCloser(bytes.NewReader(data)) + return response, nil + } + code := "mcp_protocol_error" + switch response.StatusCode { + case http.StatusUnauthorized: + code = "mcp_auth_required" + case http.StatusForbidden: + code = "mcp_permission_denied" + } + if response.StatusCode >= 300 && response.StatusCode < 400 { + code = "mcp_connection_error" + } + return nil, t.state.fail(code) + } + sse := strings.HasPrefix(strings.ToLower(response.Header.Get("Content-Type")), "text/event-stream") + response.Body = &observedBody{ + source: response.Body, + state: t.state, + method: rpc.Method, + id: rpc.ID, + sse: sse, + release: release, + } + return response, nil +} + +func maskedResponseHeaders(headers, configured http.Header) map[string]string { + result := make(map[string]string, len(headers)) + for key, values := range headers { + value := strings.Join(values, ", ") + if configured.Get(key) != "" || strings.EqualFold(key, "Authorization") || + strings.EqualFold(key, "X-Bkapi-Authorization") || + strings.EqualFold(key, "Set-Cookie") { + value = "***" + } else { + for name := range configured { + if strings.EqualFold(name, key) { + value = "***" + break + } + } + } + result[key] = value + } + return result +} + +// observedBody mirrors frames solely to preserve raw data. The SDK remains the +// SSE consumer. Both an individual event and JSON/error response are bounded. +type observedBody struct { + source io.ReadCloser + state *wireState + method string + id json.RawMessage + sse bool + data []byte + line []byte + eventBytes int + eventName string + terminal bool + afterCR bool + release func() +} + +// Close releases the response stream and its operation cancellation hook. +func (b *observedBody) Close() error { + if b.release != nil { + b.release() + } + return b.source.Close() +} + +func (b *observedBody) Read(p []byte) (int, error) { + if b.sse && b.terminal { + return 0, io.EOF + } + n, err := b.source.Read(p) + if b.sse { + for _, char := range p[:n] { + if observeErr := b.observeByte(char); observeErr != nil { + return 0, observeErr + } + } + } else { + if len(b.data)+n > MaxResponseBytes { + return 0, b.state.fail("mcp_response_too_large") + } + b.data = append(b.data, p[:n]...) + if errors.Is(err, io.EOF) && len(bytes.TrimSpace(b.data)) > 0 { + if _, observeErr := b.state.observe(b.data, b.method, b.id); observeErr != nil { + return 0, observeErr + } + } + } + return n, err +} + +func (b *observedBody) observeByte(char byte) error { + // The SDK ends this request at its first matching response. Later frames + // may be drained, but must not overwrite this or the next request's state. + if b.terminal { + return nil + } + if b.afterCR && char == '\n' { + b.afterCR = false + return nil + } + b.afterCR = char == '\r' + b.eventBytes++ + if b.eventBytes > MaxResponseBytes { + return b.state.fail("mcp_response_too_large") + } + if char != '\r' && char != '\n' { + b.line = append(b.line, char) + return nil + } + line := b.line + b.line = nil + if len(line) == 0 { + data := bytes.TrimSuffix(b.data, []byte{'\n'}) + if len(data) > 0 && (b.eventName == "" || b.eventName == "message") { + terminal, err := b.state.observe(data, b.method, b.id) + b.terminal = terminal + if err != nil { + return err + } + } + b.data = nil + b.eventBytes = 0 + b.eventName = "" + return nil + } + if value, ok := bytes.CutPrefix(line, []byte("data:")); ok { + value = bytes.TrimPrefix(value, []byte{' '}) + b.data = append(b.data, value...) + b.data = append(b.data, '\n') + } + if value, ok := bytes.CutPrefix(line, []byte("event:")); ok { + b.eventName = string(bytes.TrimPrefix(value, []byte{' '})) + } else if bytes.Equal(line, []byte("event")) { + b.eventName = "" + } + return nil +} diff --git a/skills/bk-cli-mcp/SKILL.md b/skills/bk-cli-mcp/SKILL.md new file mode 100644 index 0000000..735e6f2 --- /dev/null +++ b/skills/bk-cli-mcp/SKILL.md @@ -0,0 +1,78 @@ +--- +name: bk-cli-mcp +description: 当用户希望通过 bk-cli 连接远程 MCP server、检查连接、发现工具、查看工具 Schema、调用指定工具或排查 MCP 认证和未知执行结果时使用。 +--- + +# bk-cli MCP 使用指引 + +## 适用范围与准备 + +使用 `bk-cli mcp ` 连接配置中的 Streamable HTTP server。先确认用户授权调用的目标和工具;发现工具不意味着授权调用全部工具。有副作用的工具按用户的业务授权执行。 + +默认配置为 `~/.bk-cli/mcp.json`,或 `BK_CLI_CONFIG_DIR` 下的 `mcp.json`;`--config ` 只选一个指定文件,不合并。配置必须是严格 JSON: + +```json +{ + "mcpServers": { + "reports": { + "type": "http", + "url": "https://mcp.example.com/reports/mcp/", + "headers": {"Authorization": "Bearer ${REPORT_TOKEN}"} + } + } +} +``` + +认证只来自所选 server 的 headers,环境变量必须被显式引用;不读取 context、auth login 缓存、`.env` 或其他客户端配置。显式 `--context` 会报错,`--header/--stage/--tenant` 不支持。不要通过反复登录 bk-cli 解决 MCP 身份问题。 + +除个人 Bearer 外,可将 `X-Bkapi-Authorization` 设为 `${REPORT_BK_AUTH}`,该环境变量提供完整认证 JSON:应用 code/secret + ticket、应用 code/secret + token、或独立 access_token。不要同时配置两种认证头。无认证 server 可省略 headers,自定义 API key 也写在 headers 中。参见[用户指南](../../docs/user-guide.md#11-远程-mcp-工具)的完整示例。 + +## 推荐流程 + +```bash +# 本地帮助,不连接远端 +bk-cli mcp +bk-cli mcp reports + +# 检查并发现;只读取目录,不调用业务工具 +bk-cli mcp reports --check +bk-cli mcp reports --tools +bk-cli mcp reports search --help + +# 确认 Schema 后显式调用一个工具 +bk-cli mcp reports search --keyword '网关错误' --limit 20 --enabled=false +bk-cli mcp reports search --body '{"keyword":"网关错误","limit":20}' +bk-cli mcp reports search --body @params.json +bk-cli mcp reports search --body - < params.json + +# 最终结果筛选 +bk-cli mcp reports --tools --jq '.data.tools[].name' +bk-cli mcp reports search --body @params.json --jq '.data.result' + +# 离线预览,必须有完整 body,无参数工具用 {} +bk-cli mcp reports search --body @params.json --dry-run +``` + +server、tool 和字段名使用配置/发现返回的原名,不改写大小写、下划线或点号。未知工具应先看完整目录,不猜名称。工具帮助联网但不执行工具或读取 stdin。 + +- 字符串保持字符串;布尔值显式使用 `=true/false`,数组和对象使用 JSON。 +- 只发送明确提供的参数,不依赖 Schema default 自动补值。 +- body 是完整 arguments 对象,不是 HTTP/JSON-RPC 包装。body 覆盖全部具名参数,不合并;未知或重复 flag 仍报错。 +- 复杂 Schema、保留名(如 `help/body/config`)或无法表示为 flag 的字段通过 body 原名传入。 +- `--check`、`--tools` 与工具调用互斥。离线 dry-run 不接受具名工具参数,也不证明远端 Schema、工具存在性或权限。 +- 默认总超时 60 秒,`--timeout 90s` 覆盖整次连接、所有分页和调用。`--verbose` 输出脱敏诊断;`--insecure` 只在明确需要时使用。 + +## 结果判断与恢复 + +本地错误写 stderr、退出 1;远程失败写 stdout 的一个 `ok:false` envelope、退出 2。成功结果位于 `data.result`,其中 text 不自动解释为业务 JSON。`ok:true` 不证明异步任务完成或任意业务字段表示成功;check 成功也不证明所有工具均有权限。 + +- `mcp_auth_required`:检查所选 server headers 和其引用变量,不自动 OAuth 登录或更换身份。 +- `mcp_permission_denied`:检查服务、工具与业务权限,不一律归因于票据过期。 +- `mcp_tool_not_found` / `invalid_mcp_arguments`:本地校验失败,没有调用工具;重新查看工具帮助。 +- `mcp_capability_unsupported`:保留输入要求/任务状态供人工或外层 Agent 判断;一期不支持 input_required 接续、sampling、elicitation、异步轮询,不能直接重发原调用。 +- **`data.execution_state: "unknown"`**:请求可能已执行,完整响应未取得。先用业务查询核实,避免重复写操作。 +- jq/输出失败:读取保留的原结果;展示失败不表示请求失败,不能盲目重试。失败 envelope 不被 jq 隐藏。 + +CLI 不自动重发 `tools/call`。固定上限为单个 JSON/SSE 事件 16 MiB、累计目录 32 MiB、100 页,超限明确失败。JSON 数字在参数、目录、结果和简单 jq 字段提取中保持精度;jq 计算语义见[共享指引](../bk-cli-shared/SKILL.md#内置---jq-结果投影)。原始业务结果不承诺通用敏感内容过滤。 + +仅支持远程 Streamable HTTP,不支持 stdio、旧 HTTP+SSE、OAuth 自动登录或自动导入其他客户端配置。实现和扩展请转到 [AGENTS.md](../../AGENTS.md) 与[设计文档](../../docs/design.md),不要将使用示例作为协议实现说明。 diff --git a/skills/bk-cli-shared/SKILL.md b/skills/bk-cli-shared/SKILL.md index 27cf238..6f1a42a 100644 --- a/skills/bk-cli-shared/SKILL.md +++ b/skills/bk-cli-shared/SKILL.md @@ -18,6 +18,8 @@ description: 当任务涉及 bk-cli 的通用使用规则时使用,尤其是 2. 再读系统专属 skill,补上具体命令、字段和示例。 3. 如果系统专属命令还没覆盖目标接口,再读 `bk-cli-api` 处理原始调用。 +MCP 连接与工具调用请先读 [bk-cli-mcp](../bk-cli-mcp/SKILL.md)。`mcp` 使用独立配置和 server headers,不使用下文的 context/auth/stage/tenant/header 回退;显式 `--context` 会报错。MCP 的 `--timeout` 默认 60 秒,是整次远程操作预算;离线 dry-run 必须提供完整 body。 + ## 基础准备 ```bash @@ -44,7 +46,7 @@ bk-cli doctor --offline | Flag | 适用范围 | 说明 | |------|----------|------| -| `--context ` | 全局 | 单次命令覆盖当前激活的 BlueKing context | +| `--context ` | API / 系统及 context/auth 命令(不含 MCP) | 单次命令覆盖当前激活的 BlueKing context | | `--stage ` | API Gateway 请求 | 网关 stage,默认 `prod`,常见值为 `testing` | | `--dry-run` | 请求类命令 | 只预览请求,不发起网络请求 | | `--verbose` | 请求类命令 | 将请求/响应细节打印到 stderr | @@ -52,7 +54,7 @@ bk-cli doctor --offline | `--header 'Key:Value'` | 请求类命令,可重复 | 传入额外请求头;header name 和 value 会在本地校验 | | `--body ''` | 支持 body 的请求类命令 | 传入 JSON body,CLI 管理 `Content-Type: application/json` | | `--timeout ` | `bk-cli api` | 单次覆盖 context timeout,例如 `180s` | -| `--jq ''` | `api` 和系统 action | 对完整最终 envelope 做本地投影,输出 JSON Lines,无需安装外部 jq | +| `--jq ''` | `api`、`mcp` 和系统 action | 对完整最终 envelope 做本地投影,输出 JSON Lines,无需安装外部 jq | ## 多 Context 管理 @@ -96,7 +98,7 @@ bk-cli api bk-iam GET /api/v2/systems/ --context devops ## 内置 `--jq` 结果投影 -`api` 和系统 action 支持 `--jq ''`,不需要外部 jq。表达式的根输入是完整的最终 envelope,可读取 `.ok`、`.status`、`.headers`、`.data`;`--dry-run` 则可读取已脱敏的 `.request`。`cmdb list_biz_hosts_all` 在全部分页聚合完成后投影一次。 +`api`、`mcp` 和系统 action 支持 `--jq ''`,不需要外部 jq。表达式的根输入是完整的最终 envelope,可读取 `.ok`、`.status`、`.headers`、`.data`;`--dry-run` 则可读取已脱敏的 `.request`。`cmdb list_biz_hosts_all` 在全部分页聚合完成后投影一次。 ```bash # 提取响应 data @@ -113,7 +115,7 @@ bk-cli api bk-apigateway GET /api/v2/open/gateways/ --dry-run --jq '.request.url - 空白、语法错误或未知函数在读取 body、认证及发请求前拒绝,退出码为 1。`context`、`auth`、`doctor`、`version`、`update`、`skills` 等非请求命令不支持 `--jq`。 - `ok:false` 的 envelope 绕过投影,完整输出原结果,并保留原退出行为。投影不会新增业务成功或异步任务完成判断;`null`、`false`、`empty` 也不代表业务成功或失败。 - 投影执行失败返回 `jq_execution_error`,超出时间、结果数或输出大小预算返回 `jq_limit_exceeded`,退出码为 2。输出通道可写时,stdout 保留完整原 envelope,stderr 输出结构化错误,不输出部分投影。**真实请求此时已经执行,禁止因投影失败自动重发;先检查原结果中的任务、资源或申请单 ID,再判断下一步。** dry-run 不会提交请求。 -- 投影预算为 2 秒、10,000 个结果、16 MiB 编码输出。整数及整数运算保持精度;小数、指数和浮点运算遵循 gojq 语义,不保证任意精度或保留原数字字面量。需要原始数字字面量时省略 `--jq`。 +- 投影预算为 2 秒、10,000 个结果、16 MiB 编码输出。整数及整数运算保持精度;小数、指数和浮点运算遵循 gojq 语义,不保证任意精度或保留原数字字面量。MCP 结果的简单字段提取保持收到的数字精度;涉及计算的表达式仍遵循上述语义。需要完整原始结果时省略 `--jq`。 ## 超时规则 diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md index 6218b88..db8a380 100644 --- a/tests/integration/AGENTS.md +++ b/tests/integration/AGENTS.md @@ -49,6 +49,7 @@ Use the existing top-level coverage folders: - `cases/context/` - `cases/auth/` - `cases/api/` +- `cases/mcp/` (non-system MCP commands; use the `mcp` config seed and `/mcp` mock endpoint) ### System commands diff --git a/tests/integration/README.md b/tests/integration/README.md index 777b134..aefce1c 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -29,12 +29,13 @@ ## 当前目录布局规则 -- `context`、`auth` 和原始 `api` 场景直接放在各自对应的目录下 +- `context`、`auth`、原始 `api` 和 `mcp` 场景直接放在各自对应的目录下 - system 场景放在 `cases/system//` 下 示例: - `cases/context/CTX-001-context-create-use-list.yaml` +- `cases/mcp/MCP-001-basic.yaml`(使用 `fixtures/config/mcp` 和现有 mock 的 `/mcp` 协议入口) - `cases/system/devops/SYSGO-001-devops-start-build.yaml` - `cases/system/apigateway/SYSYAML-001-apigateway-list-gateways.yaml` diff --git a/tests/integration/cases/mcp/MCP-001-basic.yaml b/tests/integration/cases/mcp/MCP-001-basic.yaml new file mode 100644 index 0000000..7fd5069 --- /dev/null +++ b/tests/integration/cases/mcp/MCP-001-basic.yaml @@ -0,0 +1,41 @@ +id: MCP-001 +name: MCP connection discovery help and explicit call +workspace: + seed_config: mcp +steps: + - name: root help without credentials + command: [mcp, --help] + expect: + exit_code: 0 + - name: server help without credentials + command: [mcp, fixture, --help] + expect: + exit_code: 0 + - name: check connection and complete discovery + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-001} + command: [mcp, fixture, --check] + expect: + exit_code: 0 + checks: + - {path: data.checks.protocol, equals: passed} + - {path: data.complete, equals: true} + - {path: data.tool_count, equals: 4} + - name: list tools + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-001} + command: [mcp, fixture, --tools] + expect: + exit_code: 0 + checks: + - {path: data.tools, kind: list, length_at_least: 4} + - name: dynamic help + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-001} + command: [mcp, fixture, echo, --help] + expect: + exit_code: 0 + - name: explicit tool call + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-001} + command: [mcp, fixture, echo, --text, 中文] + expect: + exit_code: 0 + checks: + - {path: data.result.structuredContent.arguments.text, equals: 中文} diff --git a/tests/integration/cases/mcp/MCP-002-auth-isolation.yaml b/tests/integration/cases/mcp/MCP-002-auth-isolation.yaml new file mode 100644 index 0000000..4ab8f89 --- /dev/null +++ b/tests/integration/cases/mcp/MCP-002-auth-isolation.yaml @@ -0,0 +1,33 @@ +id: MCP-002 +name: MCP uses only configured identity +workspace: + seed_config: mcp +steps: + - name: create unrelated context + command: [context, init, '--bk_api_url_tmpl=http://unrelated/{gateway_name}'] + expect: + exit_code: 0 + - name: cache unrelated credentials + command: [auth, login, --access_token=unrelated] + expect: + exit_code: 0 + - name: MCP ignores cached identity + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-002} + command: [mcp, fixture, --check] + expect: + exit_code: 0 + checks: + - {path: data.authentication.source, equals: server_headers} + - name: explicit context is unsupported + command: [mcp, fixture, --context=default, --check] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: unsupported_option} + - name: rejected credentials do not fall back + env: {MCP_TEST_TOKEN: invalid-token, MCP_TEST_SCENARIO: MCP-002} + command: [mcp, fixture, --check] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: mcp_auth_required} diff --git a/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml b/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml new file mode 100644 index 0000000..2dd3ded --- /dev/null +++ b/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml @@ -0,0 +1,35 @@ +id: MCP-003 +name: MCP complete body and error output +workspace: + seed_config: mcp +steps: + - name: complete body overrides named values + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-003} + command: [mcp, fixture, echo, --text=ignored, --body, '{"text":"body","n":9007199254740993}'] + expect: + exit_code: 0 + checks: + - {path: data.result.structuredContent.arguments.text, equals: body} + - name: missing required argument is local + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-003} + command: [mcp, fixture, echo] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_mcp_arguments} + - name: tool error is not hidden by jq + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-003} + command: [mcp, fixture, fail, --text=x, --jq, empty] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: mcp_tool_error} + - {path: data.result.isError, equals: true} + - name: input required remains incomplete + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-003} + command: [mcp, fixture, input, --text=x] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: mcp_capability_unsupported} + - {path: data.result.requestState, equals: pending} diff --git a/tests/integration/cases/mcp/MCP-004-pagination-timeout.yaml b/tests/integration/cases/mcp/MCP-004-pagination-timeout.yaml new file mode 100644 index 0000000..84c6f1a --- /dev/null +++ b/tests/integration/cases/mcp/MCP-004-pagination-timeout.yaml @@ -0,0 +1,21 @@ +id: MCP-004 +name: MCP SSE pagination and unknown execution on timeout +workspace: + seed_config: mcp +steps: + - name: discovers both SSE pages + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-004} + command: [mcp, fixture, --tools] + expect: + exit_code: 0 + checks: + - {path: data.tool_count, equals: 4} + - {path: data.complete, equals: true} + - name: finds tool on the second page and reports uncertain outcome + env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-004} + command: [mcp, fixture, slow, --text=x, --timeout=150ms] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: mcp_timeout} + - {path: data.execution_state, equals: unknown} diff --git a/tests/integration/cases/mcp/MCP-005-offline.yaml b/tests/integration/cases/mcp/MCP-005-offline.yaml new file mode 100644 index 0000000..f3b89bf --- /dev/null +++ b/tests/integration/cases/mcp/MCP-005-offline.yaml @@ -0,0 +1,25 @@ +id: MCP-005 +name: MCP offline preview and invalid combinations +workspace: + seed_config: mcp +steps: + - name: dry run works with unreachable endpoint + command: [mcp, offline, unverified, --body, '{}', --dry-run] + expect: + exit_code: 0 + checks: + - {path: dry_run, equals: true} + - {path: data.verified_remote, equals: false} + - {path: request.headers.X-API-Key, equals: '***'} + - name: named arguments cannot be mapped offline + command: [mcp, offline, unverified, --text=x, --dry-run] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_mcp_arguments} + - name: check is incompatible with dry run + command: [mcp, offline, --check, --dry-run] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_mcp_arguments} diff --git a/tests/integration/fixtures/config/mcp/mcp.json b/tests/integration/fixtures/config/mcp/mcp.json new file mode 100644 index 0000000..e899482 --- /dev/null +++ b/tests/integration/fixtures/config/mcp/mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "fixture": { + "type": "http", + "url": "http://mock_api:8080/mcp", + "headers": { + "Authorization": "Bearer ${MCP_TEST_TOKEN}", + "X-Mock-Scenario": "${MCP_TEST_SCENARIO}" + } + }, + "offline": { + "type": "http", + "url": "http://127.0.0.1:1/not-running", + "headers": {"X-API-Key": "offline-secret"} + } + } +} diff --git a/tests/integration/mock_api/app.py b/tests/integration/mock_api/app.py index 40dba32..86b53a9 100644 --- a/tests/integration/mock_api/app.py +++ b/tests/integration/mock_api/app.py @@ -17,8 +17,10 @@ from __future__ import annotations from typing import Any +import json +import time -from flask import Flask, jsonify, request +from flask import Flask, Response, jsonify, request NEGATIVE_SCENARIOS: dict[str, tuple[int, dict[str, Any]]] = { "SYSGO-002": ( @@ -71,6 +73,47 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask: if test_config: app.config.update(test_config) + @app.route("/mcp", methods=["POST", "DELETE"]) + def mcp_endpoint(): + if request.method == "DELETE": + return "", 204 + if request.headers.get("Authorization") != "Bearer integration-mcp": + return jsonify({"error": "unauthorized"}), 401 + if request.headers.get("X-Bkapi-Authorization") or request.headers.get("X-Bk-Tenant-Id"): + return jsonify({"error": "unexpected_context_identity"}), 403 + rpc = request.get_json() + method = rpc.get("method") + if method == "server/discover": + return jsonify({"jsonrpc": "2.0", "id": rpc["id"], "error": {"code": -32601, "message": "legacy fixture"}}) + if method == "initialize": + result = {"protocolVersion": "2025-11-25", "capabilities": {"tools": {}}, "serverInfo": {"name": "integration", "version": "1"}} + elif method in ("notifications/initialized", "notifications/cancelled"): + return "", 202 + elif method == "tools/list": + tools = [{"name": name, "description": "Synthetic MCP tool", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}} for name in ("echo", "fail", "slow", "input")] + scenario = request.headers.get("X-Mock-Scenario", "") + cursor = rpc.get("params", {}).get("cursor") + result = {"tools": tools} + if scenario == "MCP-004": + result = {"tools": tools[2:] if cursor else tools[:2]} + if not cursor: + result["nextCursor"] = "second-page" + elif method == "tools/call": + params = rpc.get("params", {}) + name = params.get("name") + if name == "slow": + time.sleep(0.3) + if name == "input": + result = {"resultType": "input_required", "inputRequests": {}, "requestState": "pending"} + else: + result = {"content": [{"type": "text", "text": "fixture"}], "structuredContent": {"arguments": params.get("arguments"), "big": 9007199254740993}, "isError": name == "fail"} + else: + return jsonify({"jsonrpc": "2.0", "id": rpc.get("id"), "error": {"code": -32601, "message": "unknown method"}}) + message = json.dumps({"jsonrpc": "2.0", "id": rpc["id"], "result": result}) + if request.headers.get("X-Mock-Scenario") == "MCP-004": + return Response("event: message\ndata: " + message + "\n\n", content_type="text/event-stream") + return Response(message, content_type="application/json") + def active_scenario() -> str: return request.headers.get("X-Mock-Scenario", "").strip() From 6af47682a2f9719a5d758914e4f7f93e80072b73 Mon Sep 17 00:00:00 2001 From: wklken Date: Fri, 23 Oct 2026 09:36:52 +0800 Subject: [PATCH 3/5] feat(mcp): list configured servers and use literal headers --- AGENTS.md | 2 +- README.md | 2 +- README_EN.md | 2 +- cmd/mcp/command.go | 54 +++++++++---- cmd/mcp/command_test.go | 79 +++++++++++++++++++ docs/design.md | 6 +- docs/user-guide.md | 58 ++++++-------- internal/mcpclient/arguments.go | 2 +- internal/mcpclient/arguments_test.go | 10 ++- internal/mcpclient/auth.go | 42 ---------- internal/mcpclient/config.go | 57 +++++++------ internal/mcpclient/config_test.go | 51 +++++------- skills/bk-cli-mcp/SKILL.md | 42 +++++----- tests/integration/README.md | 10 +++ .../integration/cases/mcp/MCP-001-basic.yaml | 12 ++- .../cases/mcp/MCP-002-auth-isolation.yaml | 7 +- .../cases/mcp/MCP-003-arguments-errors.yaml | 4 - .../cases/mcp/MCP-004-pagination-timeout.yaml | 6 +- .../integration/fixtures/config/mcp/mcp.json | 25 +++++- tests/integration/mock_api/app.py | 2 + 20 files changed, 285 insertions(+), 188 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9fd53a7..be54452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,7 +322,7 @@ Write final request results through `output.WriteCommandEnvelope(cmd, env)`, aft ## MCP command boundary -`cmd/mcp` is a root command, not a public system. It uses only the selected `mcp.json` server headers and never resolves gateway context, credentials, stage or tenant. Keep SDK negotiation/session ownership in `internal/mcpclient`, preserve raw JSON numbers at the HTTP boundary, and never replay `tools/call`. See `docs/design.md` section 5.8 for version, resource-limit and result-state contracts. Keep MCP integration cases in `tests/integration/cases/mcp/` and user guidance in `skills/bk-cli-mcp/SKILL.md`. +`cmd/mcp` is a root command, not a public system. `--list` reads local configuration only and masks all header values. Header values are literal and never expanded from environment variables. Remote operations use only the selected `mcp.json` server headers and never resolve gateway context, credentials, stage or tenant. Keep SDK negotiation/session ownership in `internal/mcpclient`, preserve raw JSON numbers at the HTTP boundary, and never replay `tools/call`. See `docs/design.md` section 5.8 for version, resource-limit and result-state contracts. Keep MCP integration cases in `tests/integration/cases/mcp/` and user guidance in `skills/bk-cli-mcp/SKILL.md`. ## Runtime, auth, and request behavior diff --git a/README.md b/README.md index 988fa60..2e2a10a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ ## 功能特性 -- **远程 MCP 工具**: 从独立 `mcp.json` 连接 Streamable HTTP server,使用 `bk-cli mcp --check/--tools` 发现,再显式调用工具;认证只来自该 server 的 headers。见 [MCP 使用指南](docs/user-guide.md#11-远程-mcp-工具) - **三层调用方式**: 原始 API 调用、系统子命令,以及快捷指令(未来) - **多上下文支持**: 管理多个 BlueKing 部署的凭据 - **环境自检**: 使用 `bk-cli doctor` 检查 context、凭据、URL 渲染和网关连通性,凭据值脱敏显示 @@ -24,6 +23,7 @@ - **个人 token 登录**: `bk-cli auth login --personal_token=''`,使用个人中心生成的 token 调用支持 Bearer 鉴权的 API - **加密凭据存储**: 每个上下文使用 AES-256-GCM 加密 - **单一二进制文件**: 无运行时依赖 +- **远程 MCP 工具**: 基于独立 `mcp.json` 配置连接 Streamable HTTP 服务,支持服务列表、连接检查、工具发现、动态帮助、工具调用和离线预览 ## 快速开始 diff --git a/README_EN.md b/README_EN.md index 95086df..eca2c40 100644 --- a/README_EN.md +++ b/README_EN.md @@ -12,7 +12,6 @@ For the full design contract, see [docs/design.md](docs/design.md). ## Features -- **Remote MCP tools**: Connect to Streamable HTTP servers from a separate `mcp.json`, discover with `bk-cli mcp --check/--tools`, then explicitly call a tool. Authentication comes only from that server's headers. See the [MCP guide (Chinese)](docs/user-guide.md#11-远程-mcp-工具). - **Three layers of invocation**: raw API calls, system subcommands, and shortcuts (planned) - **Multi-context support**: manage credentials for multiple BlueKing deployments - **Environment diagnostics**: use `bk-cli doctor` to check contexts, credentials, URL rendering, and gateway connectivity, with credential values masked @@ -24,6 +23,7 @@ For the full design contract, see [docs/design.md](docs/design.md). - **Personal token login**: `bk-cli auth login --personal_token=''` uses a token from the personal center to call APIs that support Bearer authentication - **Encrypted credential storage**: AES-256-GCM encryption per context - **Single binary**: with no runtime dependencies +- **Remote MCP tools**: Connect to Streamable HTTP services using a separate `mcp.json`, with configured server listing, connection checks, tool discovery, dynamic help, tool calls, and offline previews ## Quick Start diff --git a/cmd/mcp/command.go b/cmd/mcp/command.go index 502fccf..c865135 100644 --- a/cmd/mcp/command.go +++ b/cmd/mcp/command.go @@ -39,28 +39,31 @@ import ( ) type options struct { - config, body string - timeout time.Duration - check, tools, help, dryRun, verbose, insecure bool - context string - server, tool string - named []mcpclient.NamedArgument + config, body string + timeout time.Duration + list, check, tools, help, dryRun, verbose, insecure bool + context string + server, tool string + named []mcpclient.NamedArgument } const generalHelp = `Usage: + bk-cli mcp [--config path] --list bk-cli mcp [--config path] [--check | --tools | [arguments]] Configuration: $BK_CLI_CONFIG_DIR/mcp.json, otherwise ~/.bk-cli/mcp.json. -Only the selected server's configured headers are used; context credentials are never loaded. +Only the selected server's configured headers are used, without environment expansion. +Context credentials are never loaded. Examples: - bk-cli mcp report-prod --check - bk-cli mcp report-prod --tools - bk-cli mcp report-prod search --help - bk-cli mcp report-prod search --body '{"keyword":"gateway"}' - bk-cli mcp report-prod search --body @arguments.json - bk-cli mcp report-prod search --body - < arguments.json - bk-cli mcp report-prod search --body '{}' --dry-run + bk-cli mcp --list + bk-cli mcp demo --check + bk-cli mcp demo --tools + bk-cli mcp demo search --help + bk-cli mcp demo search --body '{"keyword":"gateway"}' + bk-cli mcp demo search --body @arguments.json + bk-cli mcp demo search --body - < arguments.json + bk-cli mcp demo search --body '{}' --dry-run Options: --config, --timeout (default 60s), --jq, --verbose/-v, --insecure, --dry-run. Remote tools may have side effects. Discovery success does not prove business permissions. @@ -81,6 +84,7 @@ func NewCommand() *cobra.Command { flags.StringVar(&o.config, "config", "", "Use exactly this MCP JSON file") flags.StringVar(&o.body, "body", "", "Complete arguments JSON, @file, or - for stdin") flags.DurationVar(&o.timeout, "timeout", config.DefaultTimeout, "Total remote operation timeout") + flags.BoolVar(&o.list, "list", false, "List configured servers locally without connecting") flags.BoolVar(&o.check, "check", false, "Check connection, protocol and complete tool discovery") flags.BoolVar(&o.tools, "tools", false, "List all tools and schemas") flags.BoolVarP(&o.help, "help", "h", false, "Show help without calling a tool") @@ -202,10 +206,16 @@ func (o *options) validateMode(cmd *cobra.Command, positionals []string) error { if o.server == "" && modes > 0 { return invalidArguments() } + if o.list && (o.server != "" || modes > 0) { + return invalidArguments() + } return nil } func (o *options) run(cmd *cobra.Command) error { + if o.list && !o.help { + return o.listServers(cmd) + } if o.server == "" { _, err := fmt.Fprint(cmd.OutOrStdout(), generalHelp) return err @@ -273,6 +283,22 @@ func (o *options) run(cmd *cobra.Command) error { return o.execute(cmd, server, headers) } +func (o *options) listServers(cmd *cobra.Command) error { + loaded, err := mcpclient.LoadConfig(o.config) + if err != nil { + return localError(cmd, err) + } + servers := make([]map[string]any, 0, len(loaded.Servers)) + for _, server := range loaded.Servers { + servers = append(servers, map[string]any{ + "name": server.Name, "transport": "http", "url": server.URL, "headers": server.MaskedHeaders(), + }) + } + return writeResult(cmd, output.SuccessData(map[string]any{ + "config_file": loaded.File, "server_count": len(servers), "servers": servers, + })) +} + func (o *options) execute(cmd *cobra.Command, server *mcpclient.Server, headers http.Header) error { interrupted, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) defer stop() diff --git a/cmd/mcp/command_test.go b/cmd/mcp/command_test.go index 178f101..6d6cc72 100644 --- a/cmd/mcp/command_test.go +++ b/cmd/mcp/command_test.go @@ -51,6 +51,7 @@ var _ = Describe("MCP command", func() { var endpoint, dir string var result string var callDelay time.Duration + var receivedHeaders chan http.Header run := func(args ...string) (string, string, error) { cmd := NewCommand() var out, stderr bytes.Buffer @@ -68,8 +69,10 @@ var _ = Describe("MCP command", func() { GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) result = `{"content":[],"structuredContent":{"n":9007199254740993,"decimal":0.123456789012345678901}}` callDelay = 0 + receivedHeaders = make(chan http.Header, 64) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) + receivedHeaders <- r.Header.Clone() if r.Method == "DELETE" { w.WriteHeader(204) return @@ -129,6 +132,82 @@ var _ = Describe("MCP command", func() { _, _, err := run("--help") Expect(err).NotTo(HaveOccurred()) }) + It("lists configured servers offline with stable names and masked headers", func() { + path := filepath.Join(dir, "other.json") + content := `{"mcpServers":{"zeta":{"type":"http","url":"http://127.0.0.1:1/z","headers":{"Authorization":"Bearer ${UNSET}"}},"alpha":{"type":"http","url":"http://127.0.0.1:1/a","headers":{"X-Key":"private-secret"}}}}` + Expect(os.WriteFile(path, []byte(content), 0o600)).To(Succeed()) + out, stderr, err := run("--list", "--config", path) + Expect(err).NotTo(HaveOccurred()) + Expect(stderr).To(BeEmpty()) + var env struct { + Data struct { + ConfigFile string `json:"config_file"` + ServerCount int `json:"server_count"` + Servers []struct { + Name, URL, Transport string + Headers map[string]string + } `json:"servers"` + } + } + Expect(json.Unmarshal([]byte(out), &env)).To(Succeed()) + Expect(env.Data.ConfigFile).To(Equal(path)) + Expect(env.Data.ServerCount).To(Equal(2)) + Expect(env.Data.Servers).To(HaveLen(2)) + Expect(env.Data.Servers[0].Name).To(Equal("alpha")) + Expect(env.Data.Servers[0].URL).To(Equal("http://127.0.0.1:1/a")) + Expect(env.Data.Servers[0].Transport).To(Equal("http")) + Expect(env.Data.Servers[0].Headers).To(Equal(map[string]string{"X-Key": "***"})) + Expect(env.Data.Servers[1].Name).To(Equal("zeta")) + Expect(out).NotTo(ContainSubstring("private-secret")) + Expect(out).NotTo(ContainSubstring("${UNSET}")) + out, _, err = run("--list", "--jq", ".data.servers[].name") + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(Equal("\"test\"\n")) + Expect(requests.Load()).To(BeZero()) + }) + It("lists empty configuration and reports missing or malformed files locally", func() { + path := filepath.Join(dir, "mcp.json") + Expect(os.WriteFile(path, []byte(`{"mcpServers":{}}`), 0o600)).To(Succeed()) + out, _, err := run("--list", "--jq", ".data.servers") + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(Equal("[]\n")) + Expect(os.WriteFile(path, []byte(`{"mcpServers":{"bad":{}}}`), 0o600)).To(Succeed()) + out, stderr, err := run("--list") + Expect(err).To(HaveOccurred()) + Expect(out).To(BeEmpty()) + Expect(stderr).To(ContainSubstring("invalid_mcp_config")) + Expect(os.Remove(path)).To(Succeed()) + out, stderr, err = run("--list") + Expect(err).To(HaveOccurred()) + Expect(out).To(BeEmpty()) + Expect(stderr).To(ContainSubstring("mcp_config_not_found")) + out, _, err = run("--list", "--help") + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring("Usage:")) + Expect(requests.Load()).To(BeZero()) + }) + It("rejects list mode combined with a target or remote execution", func() { + for _, args := range [][]string{{"--list", "test"}, {"--list", "--check"}, {"--list", "--tools"}, {"--list", "--body={}"}, {"--list", "--dry-run"}, {"--list", "--text=x"}, {"--list", "--context=x"}} { + out, _, err := run(args...) + Expect(err).To(HaveOccurred()) + Expect(out).To(BeEmpty()) + } + Expect(requests.Load()).To(BeZero()) + }) + It("sends configured headers literally without environment rendering", func() { + GinkgoT().Setenv("MCP_LITERAL_SECRET", "changed-by-environment") + content := fmt.Sprintf( + `{"mcpServers":{"test":{"type":"http","url":%q,"headers":{"X-Key":"${MCP_LITERAL_SECRET}/$${missing}"}}}}`, + endpoint, + ) + Expect(os.WriteFile(filepath.Join(dir, "mcp.json"), []byte(content), 0o600)).To(Succeed()) + _, _, err := run("test", "echo", "--text=x") + Expect(err).NotTo(HaveOccurred()) + Expect(len(receivedHeaders)).To(BeNumerically(">", 0)) + for len(receivedHeaders) > 0 { + Expect((<-receivedHeaders).Get("X-Key")).To(Equal("${MCP_LITERAL_SECRET}/$${missing}")) + } + }) It("tool help discovers but does not read a body or call", func() { out, _, err := run("test", "echo", "--help", "--body", "-") Expect(err).NotTo(HaveOccurred()) diff --git a/docs/design.md b/docs/design.md index 168977a..bedd589 100644 --- a/docs/design.md +++ b/docs/design.md @@ -263,7 +263,8 @@ flowchart TD 配置与输入契约: - `LoadServer` 只选择显式 `--config` 文件或 `config.BaseDirectory()/mcp.json`;相对配置路径及 `--body @file` 均按当前工作目录解析。严格校验整个文件,拒绝未知字段、重复 key、不合法的 server 名、URL 和 header,不读取 active context 或凭据。 -- `ResolveHeaders` 只展开所选 server 的 `${ENV_NAME}`,`$${` 表示字面 `${`;不递归展开、执行 shell、加载 `.env` 或回写。根帮助不读配置,server 静态帮助不展开环境变量;tool 帮助仅连接并发现,不读取 body/stdin 或执行工具。 +- `LoadConfig` 读取完整配置并按名称排序。`bk-cli mcp --list` 离线输出 `config_file/server_count/servers`;每项保留 name、transport、url 和全部脱敏 header。空配置返回空列表,结构错误仍报本地错误,不验证凭据有效性或连接状态;支持 `--config` 和最终 `--jq`,不接受 server/tool、check/tools 或 dry-run。 +- `ResolveHeaders` 校验所选 server 的认证形态,逐值原样复制 headers;配置值不做环境变量展开或模板渲染,`${...}`/`$${...}` 没有特殊含义。不读取凭据环境变量、执行 shell、加载 `.env` 或回写。根帮助不读配置,server 静态帮助只读配置结构;tool 帮助仅连接并发现,不读取 body/stdin 或执行工具。 - 支持 Bearer、应用 code/secret + ticket 或 token、独立 access_token、其他合法 scheme 和自定义头。拒绝两种认证头并存、空票据或非法蓝鲸 JSON。所有实际 HTTP 请求使用同一份已解析 headers;不跟随重定向,默认校验 HTTPS 证书。 - 禁止配置覆盖 Host、内容类型、长度、Accept、连接控制、Last-Event-ID 和 `Mcp-*` 协议头。诊断中的全部配置 header 值固定为 `***`;响应同名头及已知认证头也脱敏。原始工具业务结果不做通用内容 DLP。 - `ReadArguments` 将内联 JSON、`@file`、stdin 解析为完整对象;拒绝重复字段、非对象、尾随内容、非法 UTF-8。数字保留为 `json.Number`。`BuildArguments` 拒绝重复/未知具名参数,body 完全覆盖已识别具名值,不合并、不注入 default。 @@ -292,7 +293,6 @@ SDK 与数字保真(2026-10-22 验证): | 对象 | 上限与行为 | | --- | --- | | 配置/arguments JSON | 16 MiB;容器嵌套最多 128 层;错误为本地输入错误 | -| 单个展开后的 header | 16 MiB | | 数值类型/enum 校验 | 字面量最多 4096 字符,显式指数绝对值最多 10000;超限明确拒绝,接受的值不截断 | | JSON 响应(含 HTTP 错误体)/单条 SSE 事件 | 16 MiB,在读取过程中限制;`mcp_response_too_large` | | 累计工具目录 | 原始分页 JSON 报文累计 32 MiB;`mcp_response_too_large` | @@ -300,7 +300,7 @@ SDK 与数字保真(2026-10-22 验证): 远端 JSON 同样拒绝重复 key、非法 UTF-8 和超过 128 层的嵌套。限制不截断后返回成功;调用结果超限且未取得完整结果按 unknown 处理。上限容纳常规 Schema/结果并限制单次 CLI 的内存和发现成本,不提供调节 flags。 -验收入口:`internal/mcpclient/*_test.go` 覆盖配置/认证/精度/协议/分页/大小限制/单次调用,`cmd/mcp/*_test.go` 与 `cmd/mcp_test.go` 覆盖模式、输出、帮助、context 隔离和 jq;`tests/integration/cases/mcp/` 的 MCP-001 至 MCP-005 覆盖构建后 CLI。真实环境验收清单见用户指南,mock 通过不代表真实服务已联调。 +验收入口:`internal/mcpclient/*_test.go` 覆盖配置/认证/精度/协议/分页/大小限制/单次调用,`cmd/mcp/*_test.go` 与 `cmd/mcp_test.go` 覆盖模式、输出、帮助、context 隔离和 jq;`tests/integration/cases/mcp/` 的 MCP-001 至 MCP-005 覆盖构建后 CLI。真实环境验收清单见 `tests/integration/README.md`,mock 通过不代表真实服务已联调。 ### 5.9 `cmd/*` diff --git a/docs/user-guide.md b/docs/user-guide.md index b539140..7eeb387 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -670,15 +670,15 @@ bk-cli api bk-apigateway GET /api/v2/open/gateways/{gateway_name}/resources/ \ ```json { "mcpServers": { - "report-personal": { + "demo": { "type": "http", - "url": "https://mcp.example.com/report/application/mcp/", - "headers": {"Authorization": "Bearer ${REPORT_PERSONAL_TOKEN}"} + "url": "https://mcp.example.com/demo/mcp/", + "headers": {"Authorization": "Bearer your-personal-token"} }, - "report-app": { + "demo-app": { "type": "http", - "url": "https://mcp.example.com/report/application/mcp/", - "headers": {"X-Bkapi-Authorization": "${REPORT_BK_AUTH}"} + "url": "https://mcp.example.com/demo/mcp/", + "headers": {"X-Bkapi-Authorization": "{\"access_token\":\"your-access-token\"}"} }, "public-docs": { "type": "http", @@ -688,7 +688,7 @@ bk-cli api bk-apigateway GET /api/v2/open/gateways/{gateway_name}/resources/ \ } ``` -只展开所选 server 的 header 中显式声明的 `${ENV_NAME}`。从安全的凭据来源设置这些环境变量;`REPORT_BK_AUTH` 应是完整 JSON 对象字符串,支持以下三种独立形态: +配置文件中的 header 字符串就是最终发送值,不做环境变量展开或模板渲染。请直接填写凭据;`${NAME}` 和 `$${NAME}` 没有特殊含义,也不会读取对应环境变量。蓝鲸 `X-Bkapi-Authorization` 的值是 JSON 对象字符串,支持以下三种独立形态(写进配置时需转义内层引号): ```json {"bk_app_code":"your_app","bk_app_secret":"your_secret","bk_ticket":"your_ticket"} @@ -696,39 +696,43 @@ bk-cli api bk-apigateway GET /api/v2/open/gateways/{gateway_name}/resources/ \ {"access_token":"your_access_token"} ``` -不要同时配置 `Authorization` 与 `X-Bkapi-Authorization`。自定义 API key header 和第三方合法 Authorization scheme 也可透传。缺失环境变量、空认证值、重复配置字段或不支持的配置选项会明确失败,不自动回退匿名或其他身份。无认证 server 可以省略 headers。`$${` 表示字面 `${`;不会执行 shell、递归展开或读取 `.env`。 +不要同时配置 `Authorization` 与 `X-Bkapi-Authorization`。自定义 API key header 和第三方合法 Authorization scheme 也可透传。空认证值、重复配置字段或不支持的配置选项会明确失败,不自动回退匿名或其他身份。无认证 server 可以省略 headers。不会执行 shell 或读取 `.env`。 含真实票据的文件应仅允许当前用户读写,不提交版本库。配置和错误诊断中的全部 header 值都显示为 `***`;工具返回的业务数据原样保留,仍应按其敏感程度处理。 ### 检查、发现、帮助与调用 ```bash -# 本地帮助,不需要票据环境变量,不连接远端 +# 本地帮助,只展示用法;不连接远端 bk-cli mcp -bk-cli mcp report-personal +bk-cli mcp demo + +# 查看当前配置文件中的服务列表,不联网;header 值统一脱敏 +bk-cli mcp --list +bk-cli mcp --config ./mcp.json --list # 检查协议和完整目录;查看完整工具定义 -bk-cli mcp report-personal --check -bk-cli mcp report-personal --tools -bk-cli mcp report-personal search_reports --help +bk-cli mcp demo --check +bk-cli mcp demo --tools +bk-cli mcp demo search --help # 按远端 Schema 的原始字段名传参;布尔值显式赋值 -bk-cli mcp report-personal search_reports --keyword '网关错误' --limit 20 --enabled=false +bk-cli mcp demo search --keyword '网关错误' --limit 20 --enabled=false # 完整 arguments 对象,不是 JSON-RPC 报文;覆盖全部具名参数 -bk-cli mcp report-personal search_reports --body '{"keyword":"网关错误","limit":20}' -bk-cli mcp report-personal search_reports --body @params.json -bk-cli mcp report-personal search_reports --body - < params.json +bk-cli mcp demo search --body '{"keyword":"网关错误","limit":20}' +bk-cli mcp demo search --body @params.json +bk-cli mcp demo search --body - < params.json # 只用指定配置;提取最终结果 -bk-cli mcp --config ./mcp.json report-personal --tools --jq '.data.tools[].name' -bk-cli mcp report-personal search_reports --body @params.json --jq '.data.result' +bk-cli mcp --config ./mcp.json demo --tools --jq '.data.tools[].name' +bk-cli mcp demo search --body @params.json --jq '.data.result' # 完全离线的预览;无参数工具也要显式提供 {} -bk-cli mcp report-personal search_reports --body @params.json --dry-run +bk-cli mcp demo search --body @params.json --dry-run ``` -示例 server/tool/字段名需换成实际配置和发现结果,名称大小写敏感,保留下划线、连字符和点号。工具帮助会联网获取 Schema,但不会调用工具或读取 body/stdin。`--check`、`--tools`、tool 调用互斥;不提供 `--header/--stage/--tenant`。复杂 Schema、无法映射的名字以及 `body/help/config` 等保留名字段使用完整 body,不自动改名。具名数组和对象使用 JSON,不能用逗号分隔代替。只发送显式值,不注入 Schema default。body 顶层必须是对象,无参数用 `{}`。 +示例 server/tool/字段名需换成实际配置和发现结果,名称大小写敏感,保留下划线、连字符和点号。工具帮助会联网获取 Schema,但不会调用工具或读取 body/stdin。`--list` 不接受 server/tool 或其他执行模式;`--check`、`--tools`、tool 调用互斥;不提供 `--header/--stage/--tenant`。复杂 Schema、无法映射的名字以及 `body/help/config` 等保留名字段使用完整 body,不自动改名。具名数组和对象使用 JSON,不能用逗号分隔代替。只发送显式值,不注入 Schema default。body 顶层必须是对象,无参数用 `{}`。 `--timeout 90s` 设置本次连接、所有发现页和工具调用的总时间,默认 60 秒。`--verbose` 在 stderr 输出已脱敏的诊断;`--insecure` 仅用于明确需要跳过证书验证的环境。重定向不会被跟随,应直接配置最终 endpoint。 @@ -739,7 +743,7 @@ bk-cli mcp report-personal search_reports --body @params.json --dry-run | 本地配置/参数错误 | stderr 结构化错误,退出 1;修正配置或参数,工具未调用 | | check/tools 成功 | stdout `ok:true`、`complete:true`;只证明本次连接/发现成功,不证明所有业务权限 | | 同步工具成功 | stdout `data.result`,退出 0;仍按工具业务契约判断数据,任务 ID 不代表任务已完成 | -| 401 / 403 | 退出 2;分别检查所选 headers 的认证和该身份的服务/工具/业务权限,不会自动登录或切换凭据 | +| 401 / 403 | 退出 2;分别检查所选配置 headers 的认证和该身份的服务/工具/业务权限,不会自动登录或切换凭据 | | 工具 `isError:true` | 退出 2,保留原始结果;检查 `data.result` | | `mcp_capability_unsupported` | 退出 2,保留输入要求或 task 标识;不自动补充输入、轮询或再次调用 | | `data.execution_state: "unknown"` | 工具请求可能已执行,但完整结果丢失;先通过业务查询核实,不能直接重跑写操作 | @@ -748,13 +752,3 @@ bk-cli mcp report-personal search_reports --body @params.json --dry-run 每次 CLI 执行最多发送一次 `tools/call`。一期不支持 OAuth 自动登录、stdio、旧 HTTP+SSE、sampling/elicitation、input_required 接续或异步 task 轮询。HTTP 200 不单独代表成功;失败 envelope 不被 jq 隐藏。默认结果及简单 jq 字段提取保留收到的 JSON 数字精度,涉及计算的 jq 表达式仍遵循 gojq 数值语义。 固定限制为单个 JSON 输入/响应或 SSE 事件 16 MiB,累计发现目录 32 MiB,最多 100 页,JSON 最多嵌套 128 层。超过响应/目录上限报 `mcp_response_too_large`,超过分页上限报 `mcp_discovery_incomplete`,不会静默截断。离线 dry-run 只能校验配置、认证格式和 body 结构,不能证明远端工具存在、Schema 合法、票据有效或业务权限足够。 - -### 人工真实环境验收清单 - -以下清单需在有授权的测试环境手动执行。仓库 mock 测试不证明真实服务联调通过,不要求把真实票据写入测试文件。 - -1. 为同一个可控 server 分别配置个人 Bearer、应用 + ticket、应用 + token、access_token,以及可用的匿名 server;验证 check、完整 tools 和一个明确无副作用的工具。 -2. 切换或移除 active context,确认 MCP 仍使用所选配置;换成失效票据及权限不足身份,确认 401/403 分类,检查 verbose 无票据泄漏。 -3. 根据服务端日志核对初始化、发现分页、调用使用同一身份;选择包含复杂参数、大整数和元数据的工具,核对实际 arguments、结果及 jq 字段值。 -4. 在测试服务注入延迟、断流、input_required 和异步 task 响应,确认退出码、unknown/未完成状态,且每次 CLI 最多一次 tools/call;不要在生产写工具上制造超时。 -5. 检查 JSON/SSE、会话释放、证书和最终 endpoint;记录服务版本、CLI 提交、已验证协议版本及未验证项,避免把一次发现成功当成全功能验收。 diff --git a/internal/mcpclient/arguments.go b/internal/mcpclient/arguments.go index a7f4baf..5eb49f4 100644 --- a/internal/mcpclient/arguments.go +++ b/internal/mcpclient/arguments.go @@ -72,7 +72,7 @@ var parameterName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`) // ReservedParameter reports names owned or reserved by the MCP CLI contract. func ReservedParameter(name string) bool { switch name { - case "help", "h", "check", "tools", "config", "body", "body-schema", "timeout", "jq", "context", + case "help", "h", "list", "check", "tools", "config", "body", "body-schema", "timeout", "jq", "context", "dry-run", "verbose", "v", "insecure", "format", "header", "stage", "tenant", "quiet", "q": return true } diff --git a/internal/mcpclient/arguments_test.go b/internal/mcpclient/arguments_test.go index df8e395..0d1749a 100644 --- a/internal/mcpclient/arguments_test.go +++ b/internal/mcpclient/arguments_test.go @@ -201,6 +201,7 @@ var _ = Describe("MCP arguments", func() { It("keeps reserved and ambiguous fields accessible through body", func() { schema := map[string]any{"properties": map[string]any{ "body": map[string]any{"type": "string"}, + "list": map[string]any{"type": "string"}, "bad name": map[string]any{"type": "string"}, "union": map[string]any{ "type": []any{"string", "null"}, @@ -214,7 +215,14 @@ var _ = Describe("MCP arguments", func() { ).To( Equal([]Parameter{{Name: "flag", Type: "boolean", Description: "A flag", Required: true}}), ) - body := map[string]any{"body": "value", "flag": false, "bad name": "x", "union": nil, "ref": "x"} + body := map[string]any{ + "body": "value", + "list": "value", + "flag": false, + "bad name": "x", + "union": nil, + "ref": "x", + } _, err := BuildArguments(schema, nil, body) Expect(err).NotTo(HaveOccurred()) _, err = BuildArguments(schema, []NamedArgument{{"body", "value"}}, nil) diff --git a/internal/mcpclient/auth.go b/internal/mcpclient/auth.go index d3d8da4..fcf2ad2 100644 --- a/internal/mcpclient/auth.go +++ b/internal/mcpclient/auth.go @@ -20,52 +20,10 @@ package mcpclient import ( "net/http" - "os" "regexp" "strings" ) -var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -func expandHeader(value string) (string, error) { - var expanded strings.Builder - for i := 0; i < len(value); { - switch { - case strings.HasPrefix(value[i:], "$${"): - expanded.WriteString("${") - i += 3 - case strings.HasPrefix(value[i:], "${"): - end := strings.IndexByte(value[i+2:], '}') - if end < 0 { - return "", configError() - } - name := value[i+2 : i+2+end] - if !environmentName.MatchString(name) { - return "", configError() - } - replacement, ok := os.LookupEnv(name) - if !ok { - return "", &Error{ - "missing_environment_variable", - "A configured MCP header environment variable is not set", - } - } - if len(replacement) > MaxInputBytes-expanded.Len() { - return "", configError() - } - expanded.WriteString(replacement) - i += end + 3 - default: - expanded.WriteByte(value[i]) - i++ - } - if expanded.Len() > MaxInputBytes { - return "", configError() - } - } - return expanded.String(), nil -} - func validateAuth(headers http.Header) error { _, hasAuthorization := headers["Authorization"] _, hasBlueKing := headers[http.CanonicalHeaderKey("X-Bkapi-Authorization")] diff --git a/internal/mcpclient/config.go b/internal/mcpclient/config.go index 953b703..7fb8393 100644 --- a/internal/mcpclient/config.go +++ b/internal/mcpclient/config.go @@ -23,6 +23,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "unicode" @@ -30,8 +31,8 @@ import ( "github.com/TencentBlueKing/bk-cli/internal/validate" ) -// Server is the selected, structurally validated server. Headers remain literal -// until ResolveHeaders is called, so static help requires no credentials. +// Server is a structurally validated configuration entry. Header values are final +// literal values; ResolveHeaders validates authentication before remote use. type Server struct { Name string ConfigFile string @@ -39,9 +40,30 @@ type Server struct { Headers map[string]string } -// LoadServer selects exactly one file, validates its complete structure, and -// returns the exact named server without resolving credentials or reading context. +// Config contains the selected file and all structurally validated servers. +// Loading configuration never resolves credentials or connects to a server. +type Config struct { + File string + Servers []*Server +} + +// LoadServer selects an exact server name from one validated configuration file. func LoadServer(path, name string) (*Server, error) { + loaded, err := LoadConfig(path) + if err != nil { + return nil, err + } + for _, server := range loaded.Servers { + if server.Name == name { + return server, nil + } + } + return nil, &Error{"mcp_server_not_found", "Named MCP server not found in selected file"} +} + +// LoadConfig loads exactly one file and validates every server's structure. +// Servers are sorted by name for stable local listings; headers remain literal. +func LoadConfig(path string) (*Config, error) { if path == "" { path = filepath.Join(config.BaseDirectory(), "mcp.json") } @@ -69,21 +91,17 @@ func LoadServer(path, name string) (*Server, error) { if !ok { return nil, configError() } - var selected *Server + loaded := &Config{File: path, Servers: make([]*Server, 0, len(servers))} for alias, value := range servers { server, err := parseServer(alias, value) if err != nil { return nil, err } - if alias == name { - selected = server - } + server.ConfigFile = path + loaded.Servers = append(loaded.Servers, server) } - if selected == nil { - return nil, &Error{"mcp_server_not_found", "Named MCP server not found in selected file"} - } - selected.ConfigFile = path - return selected, nil + sort.Slice(loaded.Servers, func(i, j int) bool { return loaded.Servers[i].Name < loaded.Servers[j].Name }) + return loaded, nil } func parseServer(name string, raw any) (*Server, error) { @@ -170,22 +188,15 @@ func (s Server) MaskedHeaders() map[string]string { return headers } -// ResolveHeaders expands only explicit references in this server's headers and -// validates authentication. It never loads context or fallback credentials. +// ResolveHeaders validates authentication and copies configured values verbatim. +// It never expands environment variables or loads context/fallback credentials. func (s Server) ResolveHeaders() (http.Header, error) { if err := validateHeaders(s.Headers); err != nil { return nil, err } headers := make(http.Header) for name, value := range s.Headers { - expanded, err := expandHeader(value) - if err != nil { - return nil, err - } - if validate.ValidateHeaderValue(expanded) != nil { - return nil, configError() - } - headers.Set(name, expanded) + headers.Set(name, value) } if err := validateAuth(headers); err != nil { return nil, err diff --git a/internal/mcpclient/config_test.go b/internal/mcpclient/config_test.go index 2357580..06a89cf 100644 --- a/internal/mcpclient/config_test.go +++ b/internal/mcpclient/config_test.go @@ -51,21 +51,22 @@ var _ = Describe("MCP configuration", func() { _, err = LoadServer(filepath.Join(dir, "missing.json"), "test") Expect(err).To(MatchError(ContainSubstring("mcp_config_not_found"))) }) - It("keeps help independent of selected or unrelated missing variables", func() { + It("keeps configured header values literal without changing the file", func() { path := write( - `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":{"Authorization":"Bearer ${MCP_TEST_SECRET}"}},"other":{"type":"http","url":"https://example.net","headers":{"X-Key":"${MCP_TEST_MISSING}"}}}}`, + `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":{"X-Key":"${MCP_TEST_SECRET}"}},"other":{"type":"http","url":"https://example.net","headers":{"Authorization":"invalid credential"}}}}`, ) GinkgoT().Setenv("MCP_TEST_SECRET", "secret") selected, err := LoadServer(path, "test") Expect(err).NotTo(HaveOccurred()) headers, err := selected.ResolveHeaders() Expect(err).NotTo(HaveOccurred()) - Expect(headers.Get("Authorization")).To(Equal("Bearer secret")) - Expect(selected.MaskedHeaders()).To(Equal(map[string]string{"Authorization": "***"})) + Expect(headers.Get("X-Key")).To(Equal("${MCP_TEST_SECRET}")) + Expect(selected.MaskedHeaders()).To(Equal(map[string]string{"X-Key": "***"})) content, err := os.ReadFile(path) Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("${MCP_TEST_SECRET}")) }) + DescribeTable( "rejects invalid structure without reflecting secrets", func(body string) { @@ -241,36 +242,24 @@ var _ = Describe("Server header resolution", func() { "invalid_mcp_config", ), ) - It("expands only explicit references, once, and supports literal escapes", func() { - GinkgoT().Setenv("MCP_TEST_A", "${MCP_TEST_B}") - GinkgoT().Setenv("MCP_TEST_B", "SECRET") + DescribeTable("preserves template-like text literally", func(value string) { + GinkgoT().Setenv("MCP_TEST_SECRET", "SECRET") + GinkgoT().Setenv("MCP_TEST_BAD", "SECRET\r\n") GinkgoT().Setenv("BK_CLI_PERSONAL_TOKEN", "unrelated") - server := Server{ - Headers: map[string]string{"X-Key": "${MCP_TEST_A}/${MCP_TEST_B}/$${literal}/$(whoami)"}, - } - headers, err := server.ResolveHeaders() + headers, err := (Server{Headers: map[string]string{"X-Key": value}}).ResolveHeaders() Expect(err).NotTo(HaveOccurred()) - Expect(headers.Get("X-Key")).To(Equal("${MCP_TEST_B}/SECRET/${literal}/$(whoami)")) + Expect(headers.Get("X-Key")).To(Equal(value)) Expect(headers.Get("Authorization")).To(BeEmpty()) - }) - DescribeTable("rejects unsupported template syntax", func(value string) { - _, err := (Server{Headers: map[string]string{"X-Key": value}}).ResolveHeaders() - Expect(err).To(MatchError(ContainSubstring("invalid_mcp_config"))) - }, Entry("unterminated", "${SECRET"), Entry("client syntax", "${env:SECRET}"), Entry("default", "${SECRET:-default}"), Entry("empty", "${}"), Entry("leading digit", "${1SECRET}")) - It("rejects missing variables and expanded control characters", func() { - name := "BK_CLI_MCP_TEST_UNSET_42" - old, exists := os.LookupEnv(name) - Expect(os.Unsetenv(name)).To(Succeed()) - DeferCleanup(func() { - if exists { - Expect(os.Setenv(name, old)).To(Succeed()) - } - }) - _, err := (Server{Headers: map[string]string{"X-Key": "${" + name + "}"}}).ResolveHeaders() - Expect(err).To(MatchError(ContainSubstring("missing_environment_variable"))) - GinkgoT().Setenv("MCP_TEST_BAD", "SECRET\r\n") - _, err = (Server{Headers: map[string]string{"X-Key": "${MCP_TEST_BAD}"}}).ResolveHeaders() + }, Entry("existing variable", "${MCP_TEST_SECRET}"), + Entry("missing variable", "${MCP_VARIABLE_NOT_CONFIGURED_42}"), + Entry("escape", "$${MCP_TEST_SECRET}"), + Entry("control characters in environment", "${MCP_TEST_BAD}"), + Entry("unterminated", "${SECRET"), Entry("client syntax", "${env:SECRET}"), + Entry("default", "${SECRET:-default}"), Entry("empty", "${}"), + Entry("shell text", "$(whoami)/`whoami`")) + It("does not turn a templated bearer value into a valid credential", func() { + GinkgoT().Setenv("MCP_TEST_SECRET", "valid-token") + _, err := (Server{Headers: map[string]string{"Authorization": "Bearer ${MCP_TEST_SECRET}"}}).ResolveHeaders() Expect(err).To(MatchError(ContainSubstring("invalid_mcp_config"))) - Expect(err.Error()).NotTo(ContainSubstring("SECRET")) }) }) diff --git a/skills/bk-cli-mcp/SKILL.md b/skills/bk-cli-mcp/SKILL.md index 735e6f2..43e22b3 100644 --- a/skills/bk-cli-mcp/SKILL.md +++ b/skills/bk-cli-mcp/SKILL.md @@ -14,43 +14,47 @@ description: 当用户希望通过 bk-cli 连接远程 MCP server、检查连接 ```json { "mcpServers": { - "reports": { + "demo": { "type": "http", - "url": "https://mcp.example.com/reports/mcp/", - "headers": {"Authorization": "Bearer ${REPORT_TOKEN}"} + "url": "https://mcp.example.com/demo/mcp/", + "headers": {"Authorization": "Bearer your-personal-token"} } } } ``` -认证只来自所选 server 的 headers,环境变量必须被显式引用;不读取 context、auth login 缓存、`.env` 或其他客户端配置。显式 `--context` 会报错,`--header/--stage/--tenant` 不支持。不要通过反复登录 bk-cli 解决 MCP 身份问题。 +认证只来自所选 server 的 headers,配置字符串就是最终发送值,不支持环境变量展开或模板渲染;不读取 context、auth login 缓存、`.env` 或其他客户端配置。显式 `--context` 会报错,`--header/--stage/--tenant` 不支持。不要通过反复登录 bk-cli 解决 MCP 身份问题。 -除个人 Bearer 外,可将 `X-Bkapi-Authorization` 设为 `${REPORT_BK_AUTH}`,该环境变量提供完整认证 JSON:应用 code/secret + ticket、应用 code/secret + token、或独立 access_token。不要同时配置两种认证头。无认证 server 可省略 headers,自定义 API key 也写在 headers 中。参见[用户指南](../../docs/user-guide.md#11-远程-mcp-工具)的完整示例。 +除个人 Bearer 外,可将 `X-Bkapi-Authorization` 设为完整认证 JSON 字符串(配置中转义内层引号):应用 code/secret + ticket、应用 code/secret + token、或独立 access_token。不要同时配置两种认证头。无认证 server 可省略 headers,自定义 API key 也写在 headers 中。参见[用户指南](../../docs/user-guide.md#11-远程-mcp-工具)的完整示例。 ## 推荐流程 ```bash -# 本地帮助,不连接远端 +# 本地帮助,只展示用法,不列出服务或连接远端 bk-cli mcp -bk-cli mcp reports +bk-cli mcp demo + +# 列出当前配置的服务(只读本地文件,全部 header 值脱敏) +bk-cli mcp --list +bk-cli mcp --config ./mcp.json --list # 检查并发现;只读取目录,不调用业务工具 -bk-cli mcp reports --check -bk-cli mcp reports --tools -bk-cli mcp reports search --help +bk-cli mcp demo --check +bk-cli mcp demo --tools +bk-cli mcp demo search --help # 确认 Schema 后显式调用一个工具 -bk-cli mcp reports search --keyword '网关错误' --limit 20 --enabled=false -bk-cli mcp reports search --body '{"keyword":"网关错误","limit":20}' -bk-cli mcp reports search --body @params.json -bk-cli mcp reports search --body - < params.json +bk-cli mcp demo search --keyword '网关错误' --limit 20 --enabled=false +bk-cli mcp demo search --body '{"keyword":"网关错误","limit":20}' +bk-cli mcp demo search --body @params.json +bk-cli mcp demo search --body - < params.json # 最终结果筛选 -bk-cli mcp reports --tools --jq '.data.tools[].name' -bk-cli mcp reports search --body @params.json --jq '.data.result' +bk-cli mcp demo --tools --jq '.data.tools[].name' +bk-cli mcp demo search --body @params.json --jq '.data.result' # 离线预览,必须有完整 body,无参数工具用 {} -bk-cli mcp reports search --body @params.json --dry-run +bk-cli mcp demo search --body @params.json --dry-run ``` server、tool 和字段名使用配置/发现返回的原名,不改写大小写、下划线或点号。未知工具应先看完整目录,不猜名称。工具帮助联网但不执行工具或读取 stdin。 @@ -59,14 +63,14 @@ server、tool 和字段名使用配置/发现返回的原名,不改写大小 - 只发送明确提供的参数,不依赖 Schema default 自动补值。 - body 是完整 arguments 对象,不是 HTTP/JSON-RPC 包装。body 覆盖全部具名参数,不合并;未知或重复 flag 仍报错。 - 复杂 Schema、保留名(如 `help/body/config`)或无法表示为 flag 的字段通过 body 原名传入。 -- `--check`、`--tools` 与工具调用互斥。离线 dry-run 不接受具名工具参数,也不证明远端 Schema、工具存在性或权限。 +- `--list` 不接受 server/tool 或其他执行模式;`--check`、`--tools` 与工具调用互斥。离线 dry-run 不接受具名工具参数,也不证明远端 Schema、工具存在性或权限。 - 默认总超时 60 秒,`--timeout 90s` 覆盖整次连接、所有分页和调用。`--verbose` 输出脱敏诊断;`--insecure` 只在明确需要时使用。 ## 结果判断与恢复 本地错误写 stderr、退出 1;远程失败写 stdout 的一个 `ok:false` envelope、退出 2。成功结果位于 `data.result`,其中 text 不自动解释为业务 JSON。`ok:true` 不证明异步任务完成或任意业务字段表示成功;check 成功也不证明所有工具均有权限。 -- `mcp_auth_required`:检查所选 server headers 和其引用变量,不自动 OAuth 登录或更换身份。 +- `mcp_auth_required`:检查所选 server 配置中的 headers,不自动 OAuth 登录或更换身份。 - `mcp_permission_denied`:检查服务、工具与业务权限,不一律归因于票据过期。 - `mcp_tool_not_found` / `invalid_mcp_arguments`:本地校验失败,没有调用工具;重新查看工具帮助。 - `mcp_capability_unsupported`:保留输入要求/任务状态供人工或外层 Agent 判断;一期不支持 input_required 接续、sampling、elicitation、异步轮询,不能直接重发原调用。 diff --git a/tests/integration/README.md b/tests/integration/README.md index aefce1c..9ab14fb 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -175,3 +175,13 @@ make test-integration CASE=tests/integration/cases/system//.yaml 面向 agent 的覆盖新增说明位于 [AGENTS.md](./AGENTS.md)。 + +## MCP 人工真实环境验收清单 + +以下清单需在有授权的测试环境手动执行。本目录的 mock 测试不证明真实服务联调通过,不要求把真实票据写入测试文件。 + +1. 为同一个可控 server 分别配置个人 Bearer、应用 + ticket、应用 + token、access_token,以及可用的匿名 server;验证 check、完整 tools 和一个明确无副作用的工具。 +2. 切换或移除 active context,确认 MCP 仍使用所选配置;换成失效票据及权限不足身份,确认 401/403 分类,检查 verbose 无票据泄漏。 +3. 根据服务端日志核对初始化、发现分页、调用使用同一身份;选择包含复杂参数、大整数和元数据的工具,核对实际 arguments、结果及 jq 字段值。 +4. 在测试服务注入延迟、断流、input_required 和异步 task 响应,确认退出码、unknown/未完成状态,且每次 CLI 最多一次 tools/call;不要在生产写工具上制造超时。 +5. 检查 JSON/SSE、会话释放、证书和最终 endpoint;记录服务版本、CLI 提交、已验证协议版本及未验证项,避免把一次发现成功当成全功能验收。 diff --git a/tests/integration/cases/mcp/MCP-001-basic.yaml b/tests/integration/cases/mcp/MCP-001-basic.yaml index 7fd5069..586ce0e 100644 --- a/tests/integration/cases/mcp/MCP-001-basic.yaml +++ b/tests/integration/cases/mcp/MCP-001-basic.yaml @@ -3,6 +3,14 @@ name: MCP connection discovery help and explicit call workspace: seed_config: mcp steps: + - name: list configured servers offline + command: [mcp, --list] + expect: + exit_code: 0 + checks: + - {path: data.server_count, equals: 4} + - {path: data.servers.0.name, equals: denied} + - {path: data.servers.0.headers.Authorization, equals: '***'} - name: root help without credentials command: [mcp, --help] expect: @@ -12,7 +20,6 @@ steps: expect: exit_code: 0 - name: check connection and complete discovery - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-001} command: [mcp, fixture, --check] expect: exit_code: 0 @@ -21,19 +28,16 @@ steps: - {path: data.complete, equals: true} - {path: data.tool_count, equals: 4} - name: list tools - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-001} command: [mcp, fixture, --tools] expect: exit_code: 0 checks: - {path: data.tools, kind: list, length_at_least: 4} - name: dynamic help - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-001} command: [mcp, fixture, echo, --help] expect: exit_code: 0 - name: explicit tool call - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-001} command: [mcp, fixture, echo, --text, 中文] expect: exit_code: 0 diff --git a/tests/integration/cases/mcp/MCP-002-auth-isolation.yaml b/tests/integration/cases/mcp/MCP-002-auth-isolation.yaml index 4ab8f89..9fb68bc 100644 --- a/tests/integration/cases/mcp/MCP-002-auth-isolation.yaml +++ b/tests/integration/cases/mcp/MCP-002-auth-isolation.yaml @@ -11,8 +11,8 @@ steps: command: [auth, login, --access_token=unrelated] expect: exit_code: 0 - - name: MCP ignores cached identity - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-002} + - name: MCP ignores cached identity and environment values + env: {MCP_TEST_TOKEN: replaced-by-environment} command: [mcp, fixture, --check] expect: exit_code: 0 @@ -25,8 +25,7 @@ steps: checks: - {path: error.code, equals: unsupported_option} - name: rejected credentials do not fall back - env: {MCP_TEST_TOKEN: invalid-token, MCP_TEST_SCENARIO: MCP-002} - command: [mcp, fixture, --check] + command: [mcp, denied, --check] expect: exit_code: 2 checks: diff --git a/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml b/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml index 2dd3ded..a27662c 100644 --- a/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml +++ b/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml @@ -4,21 +4,18 @@ workspace: seed_config: mcp steps: - name: complete body overrides named values - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-003} command: [mcp, fixture, echo, --text=ignored, --body, '{"text":"body","n":9007199254740993}'] expect: exit_code: 0 checks: - {path: data.result.structuredContent.arguments.text, equals: body} - name: missing required argument is local - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-003} command: [mcp, fixture, echo] expect: exit_code: 1 checks: - {path: error.code, equals: invalid_mcp_arguments} - name: tool error is not hidden by jq - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-003} command: [mcp, fixture, fail, --text=x, --jq, empty] expect: exit_code: 2 @@ -26,7 +23,6 @@ steps: - {path: error.code, equals: mcp_tool_error} - {path: data.result.isError, equals: true} - name: input required remains incomplete - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-003} command: [mcp, fixture, input, --text=x] expect: exit_code: 2 diff --git a/tests/integration/cases/mcp/MCP-004-pagination-timeout.yaml b/tests/integration/cases/mcp/MCP-004-pagination-timeout.yaml index 84c6f1a..aadaea3 100644 --- a/tests/integration/cases/mcp/MCP-004-pagination-timeout.yaml +++ b/tests/integration/cases/mcp/MCP-004-pagination-timeout.yaml @@ -4,16 +4,14 @@ workspace: seed_config: mcp steps: - name: discovers both SSE pages - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-004} - command: [mcp, fixture, --tools] + command: [mcp, fixture-sse, --tools] expect: exit_code: 0 checks: - {path: data.tool_count, equals: 4} - {path: data.complete, equals: true} - name: finds tool on the second page and reports uncertain outcome - env: {MCP_TEST_TOKEN: integration-mcp, MCP_TEST_SCENARIO: MCP-004} - command: [mcp, fixture, slow, --text=x, --timeout=150ms] + command: [mcp, fixture-sse, slow, --text=x, --timeout=150ms] expect: exit_code: 2 checks: diff --git a/tests/integration/fixtures/config/mcp/mcp.json b/tests/integration/fixtures/config/mcp/mcp.json index e899482..b57931c 100644 --- a/tests/integration/fixtures/config/mcp/mcp.json +++ b/tests/integration/fixtures/config/mcp/mcp.json @@ -4,14 +4,33 @@ "type": "http", "url": "http://mock_api:8080/mcp", "headers": { - "Authorization": "Bearer ${MCP_TEST_TOKEN}", - "X-Mock-Scenario": "${MCP_TEST_SCENARIO}" + "Authorization": "Bearer integration-mcp", + "X-Mock-Scenario": "MCP-001", + "X-Literal": "${MCP_TEST_TOKEN}" } }, "offline": { "type": "http", "url": "http://127.0.0.1:1/not-running", - "headers": {"X-API-Key": "offline-secret"} + "headers": { + "X-API-Key": "offline-secret" + } + }, + "fixture-sse": { + "type": "http", + "url": "http://mock_api:8080/mcp", + "headers": { + "Authorization": "Bearer integration-mcp", + "X-Mock-Scenario": "MCP-004", + "X-Literal": "${MCP_TEST_TOKEN}" + } + }, + "denied": { + "type": "http", + "url": "http://mock_api:8080/mcp", + "headers": { + "Authorization": "Bearer invalid-token" + } } } } diff --git a/tests/integration/mock_api/app.py b/tests/integration/mock_api/app.py index 86b53a9..8ee6629 100644 --- a/tests/integration/mock_api/app.py +++ b/tests/integration/mock_api/app.py @@ -81,6 +81,8 @@ def mcp_endpoint(): return jsonify({"error": "unauthorized"}), 401 if request.headers.get("X-Bkapi-Authorization") or request.headers.get("X-Bk-Tenant-Id"): return jsonify({"error": "unexpected_context_identity"}), 403 + if request.headers.get("X-Literal") != "${MCP_TEST_TOKEN}": + return jsonify({"error": "header_value_changed"}), 400 rpc = request.get_json() method = rpc.get("method") if method == "server/discover": From b40c8f579756480204fa929d247d139b21b12381 Mon Sep 17 00:00:00 2001 From: wklken Date: Fri, 23 Oct 2026 09:57:12 +0800 Subject: [PATCH 4/5] fix(mcp): preserve RPC failures and clean up failed sessions Retain server RPC code/message, distinguish invalid authentication from configuration errors, and report safe server/field diagnostics. Allow bounded DELETE cleanup without replacing operation response headers and mask session IDs. Accept null pagination cursors and reject help combined with offline preview before network access. Keep explicit HTTP configuration and existing authentication combinations. Add regression and integration coverage for the six reviewed findings. --- cmd/mcp/command.go | 5 +- cmd/mcp/command_test.go | 69 +++++++++++++++++++ docs/design.md | 13 ++-- internal/mcpclient/auth.go | 27 +++++--- internal/mcpclient/client.go | 13 +++- internal/mcpclient/client_edges_test.go | 32 ++++++++- internal/mcpclient/config.go | 48 ++++++++++--- internal/mcpclient/config_test.go | 44 ++++++++---- internal/mcpclient/transport.go | 17 +++-- skills/bk-cli-mcp/SKILL.md | 9 ++- .../cases/mcp/MCP-003-arguments-errors.yaml | 9 +++ .../cases/mcp/MCP-005-offline.yaml | 6 ++ tests/integration/mock_api/app.py | 6 +- 13 files changed, 248 insertions(+), 50 deletions(-) diff --git a/cmd/mcp/command.go b/cmd/mcp/command.go index c865135..2443896 100644 --- a/cmd/mcp/command.go +++ b/cmd/mcp/command.go @@ -178,7 +178,7 @@ func (o *options) validateMode(cmd *cobra.Command, positionals []string) error { Message: "--context is not supported by MCP; authentication comes only from server headers", } } - if len(positionals) > 2 || o.timeout <= 0 { + if len(positionals) > 2 || o.timeout <= 0 || (o.dryRun && o.help) { return invalidArguments() } if len(positionals) > 0 { @@ -430,6 +430,9 @@ func remoteFailure(cmd *cobra.Command, client *mcpclient.Client, data map[string if !errors.As(err, &detail) { detail = &mcpclient.Error{Code: "mcp_protocol_error", Message: "MCP operation failed"} } + if rpcError := client.RPCError(); rpcError != nil { + data["rpc_error"] = rpcError + } hint := "Inspect the selected server configuration and result" if data["execution_state"] == "unknown" { hint = "The server may have executed the tool. Verify through a business query before retrying" diff --git a/cmd/mcp/command_test.go b/cmd/mcp/command_test.go index 6d6cc72..9d0e313 100644 --- a/cmd/mcp/command_test.go +++ b/cmd/mcp/command_test.go @@ -50,6 +50,7 @@ var _ = Describe("MCP command", func() { var calls, requests atomic.Int32 var endpoint, dir string var result string + var rpcError bool var callDelay time.Duration var receivedHeaders chan http.Header run := func(args ...string) (string, string, error) { @@ -69,6 +70,7 @@ var _ = Describe("MCP command", func() { GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) result = `{"content":[],"structuredContent":{"n":9007199254740993,"decimal":0.123456789012345678901}}` callDelay = 0 + rpcError = false receivedHeaders = make(chan http.Header, 64) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) @@ -106,6 +108,15 @@ var _ = Describe("MCP command", func() { ) case "tools/call": calls.Add(1) + if rpcError { + w.Header().Set("Mcp-Session-Id", "private-session") + fmt.Fprintf( + w, + `{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"Unknown tool"}}`, + req.ID, + ) + return + } if callDelay > 0 { select { case <-time.After(callDelay): @@ -120,6 +131,64 @@ var _ = Describe("MCP command", func() { endpoint = server.URL writeConfig(dir, endpoint) }) + It("preserves RPC code and message in a known failure envelope", func() { + rpcError = true + out, stderr, err := run("test", "echo", "--text", "hello") + Expect(err).To(HaveOccurred()) + Expect(stderr).To(BeEmpty()) + var env struct { + Error struct{ Code string } + Data struct { + RPCError struct { + Code int + Message string + } `json:"rpc_error"` + ExecutionState string `json:"execution_state"` + } + Headers map[string]string + } + Expect(json.Unmarshal([]byte(out), &env)).To(Succeed()) + Expect(env.Error.Code).To(Equal("mcp_rpc_error")) + Expect(env.Data.RPCError.Code).To(Equal(-32602)) + Expect(env.Data.RPCError.Message).To(Equal("Unknown tool")) + Expect(env.Data.ExecutionState).To(BeEmpty()) + Expect(out).NotTo(ContainSubstring("private-session")) + Expect(env.Headers["Mcp-Session-Id"]).To(Equal("***")) + Expect(calls.Load()).To(Equal(int32(1))) + }) + It("rejects dry-run combined with help before network or stdin access", func() { + out, stderr, err := run("test", "echo", "--dry-run", "--help", "--body", "-") + Expect(err).To(HaveOccurred()) + Expect(out).To(BeEmpty()) + Expect(stderr).To(ContainSubstring("invalid_mcp_arguments")) + Expect(requests.Load()).To(BeZero()) + }) + It("distinguishes local authentication errors from structural listing", func() { + path := filepath.Join(dir, "mcp.json") + content := fmt.Sprintf( + `{"mcpServers":{"test":{"type":"http","url":%q,"headers":{"X-Bkapi-Authorization":"{\"bk_app_code\":\"app\",\"bk_app_secret\":\"SECRET\"}"}}}}`, + endpoint, + ) + Expect(os.WriteFile(path, []byte(content), 0o600)).To(Succeed()) + _, _, err := run("--list") + Expect(err).NotTo(HaveOccurred()) + out, stderr, err := run("test", "--check") + Expect(err).To(HaveOccurred()) + Expect(out).To(BeEmpty()) + Expect( + stderr, + ).To( + And( + ContainSubstring("invalid_mcp_auth"), + ContainSubstring("test"), + ContainSubstring("bk_ticket"), + ContainSubstring("bk_token"), + ContainSubstring("access_token"), + ), + ) + Expect(stderr).NotTo(ContainSubstring("SECRET")) + Expect(requests.Load()).To(BeZero()) + }) It("root and static server help do not connect or resolve credentials", func() { for _, args := range [][]string{{}, {"--help"}, {"test"}, {"test", "-h"}} { out, stderr, err := run(args...) diff --git a/docs/design.md b/docs/design.md index bedd589..0df79a6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -262,14 +262,14 @@ flowchart TD 配置与输入契约: -- `LoadServer` 只选择显式 `--config` 文件或 `config.BaseDirectory()/mcp.json`;相对配置路径及 `--body @file` 均按当前工作目录解析。严格校验整个文件,拒绝未知字段、重复 key、不合法的 server 名、URL 和 header,不读取 active context 或凭据。 +- `LoadServer` 只选择显式 `--config` 文件或 `config.BaseDirectory()/mcp.json`;相对配置路径及 `--body @file` 均按当前工作目录解析。严格校验整个文件,拒绝未知字段、重复 key、不合法的 server 名、URL 和 header,不读取 active context 或凭据。每项必须显式声明 `type: "http"`;条目结构错误指出 server、已知字段和原因,不回显 URL、header 值或未知字段内容。 - `LoadConfig` 读取完整配置并按名称排序。`bk-cli mcp --list` 离线输出 `config_file/server_count/servers`;每项保留 name、transport、url 和全部脱敏 header。空配置返回空列表,结构错误仍报本地错误,不验证凭据有效性或连接状态;支持 `--config` 和最终 `--jq`,不接受 server/tool、check/tools 或 dry-run。 - `ResolveHeaders` 校验所选 server 的认证形态,逐值原样复制 headers;配置值不做环境变量展开或模板渲染,`${...}`/`$${...}` 没有特殊含义。不读取凭据环境变量、执行 shell、加载 `.env` 或回写。根帮助不读配置,server 静态帮助只读配置结构;tool 帮助仅连接并发现,不读取 body/stdin 或执行工具。 -- 支持 Bearer、应用 code/secret + ticket 或 token、独立 access_token、其他合法 scheme 和自定义头。拒绝两种认证头并存、空票据或非法蓝鲸 JSON。所有实际 HTTP 请求使用同一份已解析 headers;不跟随重定向,默认校验 HTTPS 证书。 -- 禁止配置覆盖 Host、内容类型、长度、Accept、连接控制、Last-Event-ID 和 `Mcp-*` 协议头。诊断中的全部配置 header 值固定为 `***`;响应同名头及已知认证头也脱敏。原始工具业务结果不做通用内容 DLP。 +- 支持 Bearer、应用 code/secret + ticket 或 token、独立 access_token、其他合法 scheme 和自定义头。拒绝两种认证头并存、空票据或非法蓝鲸 JSON。不支持仅含应用 code/secret 的组合;认证形态错误使用 `invalid_mcp_auth` 并说明 server 和支持格式,双认证头冲突仍为 `mcp_auth_conflict`。`--list` 成功只证明结构有效,不代表认证形态通过。所有实际 HTTP 请求使用同一份已解析 headers;不跟随重定向,默认校验 HTTPS 证书。 +- 禁止配置覆盖 Host、内容类型、长度、Accept、连接控制、Last-Event-ID 和 `Mcp-*` 协议头。诊断中的全部配置 header 值固定为 `***`;响应同名头、已知认证头和 `Mcp-Session-Id` 也脱敏。原始工具业务结果不做通用内容 DLP。 - `ReadArguments` 将内联 JSON、`@file`、stdin 解析为完整对象;拒绝重复字段、非对象、尾随内容、非法 UTF-8。数字保留为 `json.Number`。`BuildArguments` 拒绝重复/未知具名参数,body 完全覆盖已识别具名值,不合并、不注入 default。 - `Parameters` 映射可明确表示的 string/integer/number/boolean/array/object 字段;保留名、组合、引用和无法表示的名字仅用 body 原名传入。校验明确的类型、required、enum、嵌套 properties/items;数值 enum 精确比较。其他 JSON Schema 关键字交由服务端,不访问远程 `$ref`。 -- 显式 `--context` 报 `unsupported_option`。不支持 `--header/--stage/--tenant`;`--timeout` 默认 60 秒,覆盖连接、全部分页和调用的总预算,不读取 context。取消或超时结束本次会话。离线 dry-run 必须带完整 body,拒绝具名工具参数,零网络并标记未验证远端。 +- 显式 `--context` 报 `unsupported_option`。不支持 `--header/--stage/--tenant`;`--timeout` 默认 60 秒,覆盖连接、全部分页和调用的总预算,不读取 context。取消或超时结束本次会话。离线 dry-run 必须带完整 body,拒绝具名工具参数及与 `--help` 组合,零网络并标记未验证远端。 SDK 与数字保真(2026-10-22 验证): @@ -280,10 +280,11 @@ SDK 与数字保真(2026-10-22 验证): 生命周期和结果契约: - 测试覆盖 `2025-03-26`、`2025-06-18`、`2025-11-25`、`2026-07-28` 的 JSON/SSE 发现与调用。由 SDK 处理 `server/discover` 及旧协议初始化差异,不自行降级 transport。 -- 显式关闭 SDK 多轮输入、负载退避重发、独立 SSE 及重连;HTTP 请求移除 replay body,并统计/拒绝第二次 `tools/call`。发现和调用复用一次 client 生命周期,结束关闭会话和空闲连接。 -- 分页直到无 nextCursor,拒绝循环游标、重名工具、畸形目录;任何中途错误不得输出完整成功。空 tools 列表成功。目录不持久缓存。 +- 显式关闭 SDK 多轮输入、负载退避重发、独立 SSE 及重连;HTTP 请求移除 replay body,并统计/拒绝第二次 `tools/call`。发现和调用复用一次 client 生命周期,结束关闭会话和空闲连接。失败短路允许 SDK DELETE 清理通过,但仍受总超时/取消预算约束,不允许重发工具调用;清理响应不覆盖业务请求的响应头诊断。 +- 分页直到 nextCursor 缺省、空字符串或 null,拒绝循环游标、重名工具、畸形目录;任何中途错误不得输出完整成功。空 tools 列表成功。目录不持久缓存。 - `--check` 输出 config/connection/protocol/tools 阶段以及 `complete`;发现通过只表示该请求被接受,不证明 token 或所有业务权限有效。`--tools` 保留完整描述和元数据。 - 本地输入/配置/参数错误走 stderr、退出 1,即使参数错误发生在发现之后。远程错误走一个 stdout 失败 envelope、退出 2;401/403 分别为 `mcp_auth_required`/`mcp_permission_denied`,不换身份或重试。 +- `tools/list` 和 `tools/call` 的合法 JSON-RPC error 使用 `mcp_rpc_error`,在 `data.rpc_error` 保留服务端 code/message(与工具结果一样不做通用内容 DLP);安全诊断不包含这段远端文本。合法调用错误属于已知结果,不标记 unknown;畸形错误仍为 `mcp_protocol_error`。 - 调用保留 `data.server/tool/result`。`isError:true` 为 `mcp_tool_error`;input_required、sampling、elicitation 或 task 状态为 `mcp_capability_unsupported`,保留可用原始要求/标识,不接续、不伪报完成。 - 调用已尝试但没有完整可判定结果时设 `data.execution_state: "unknown"`,保留实际错误码,提示先业务查询再决定重试。完整结果后的 jq/输出错误不改为 unknown,不重发。`ok:true` 不解释业务 JSON 的 code/result/status,不证明异步任务完成。 - jq 在本地解析阶段编译,只对聚合/分类后的最终 envelope 执行一次;失败 envelope 不被投影隐藏,投影失败保留原结果。 diff --git a/internal/mcpclient/auth.go b/internal/mcpclient/auth.go index fcf2ad2..6028c7f 100644 --- a/internal/mcpclient/auth.go +++ b/internal/mcpclient/auth.go @@ -34,10 +34,10 @@ func validateAuth(headers http.Header) error { value := headers.Get("Authorization") fields := strings.Fields(value) if len(fields) == 0 { - return configError() + return invalidAuth("Authorization must be non-empty; Bearer requires one valid token") } if strings.EqualFold(fields[0], "Bearer") && (len(fields) != 2 || !validBearer(fields[1])) { - return configError() + return invalidAuth("Authorization must be non-empty; Bearer requires one valid token") } } if hasBlueKing { @@ -53,34 +53,45 @@ func validBearer(value string) bool { return bearerToken.MatchString(value) } func validateBlueKingAuth(value string) error { raw, err := decodeJSON(strings.NewReader(value)) if err != nil { - return configError() + return invalidBlueKingAuth() } fields, ok := raw.(map[string]any) if !ok { - return configError() + return invalidBlueKingAuth() } auth := make(map[string]string) for key, value := range fields { switch key { case "access_token", "bk_app_code", "bk_app_secret", "bk_token", "bk_ticket": default: - return configError() + return invalidBlueKingAuth() } text, ok := value.(string) if !ok || strings.TrimSpace(text) == "" { - return configError() + return invalidBlueKingAuth() } auth[key] = text } if auth["access_token"] != "" { if len(auth) != 1 { - return configError() + return invalidBlueKingAuth() } return nil } if len(auth) != 3 || auth["bk_app_code"] == "" || auth["bk_app_secret"] == "" || (auth["bk_token"] == "") == (auth["bk_ticket"] == "") { - return configError() + return invalidBlueKingAuth() } return nil } + +func invalidAuth(message string) error { + return &Error{"invalid_mcp_auth", message} +} + +func invalidBlueKingAuth() error { + return invalidAuth( + "X-Bkapi-Authorization requires exactly access_token, or bk_app_code + bk_app_secret + " + + "one of bk_ticket/bk_token; all values must be non-empty strings", + ) +} diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go index adaf0b6..0db0da4 100644 --- a/internal/mcpclient/client.go +++ b/internal/mcpclient/client.go @@ -104,13 +104,22 @@ func (c *Client) ProtocolVersion() string { return c.session.InitializeResult().ProtocolVersion } -// Headers returns the last response headers with configured/known secrets masked. +// Headers returns the last operation response headers, excluding session cleanup, +// with configured/known secrets masked. func (c *Client) Headers() map[string]string { c.state.mu.Lock() defer c.state.mu.Unlock() return maps.Clone(c.state.headers) } +// RPCError returns the server's validated JSON-RPC code and message, if present. +// Like tool results, this remote payload is kept separate from safe diagnostics. +func (c *Client) RPCError() map[string]any { + c.state.mu.Lock() + defer c.state.mu.Unlock() + return maps.Clone(c.state.rpcError) +} + // CallAttempted reports whether a tools/call entered the HTTP transport. func (c *Client) CallAttempted() bool { c.state.mu.Lock() @@ -182,7 +191,7 @@ func (c *Client) Discover() ([]map[string]any, error) { tools = append(tools, tool) } next, exists := raw["nextCursor"] - if !exists || next == "" { + if !exists || next == nil || next == "" { return tools, nil } cursor, ok = next.(string) diff --git a/internal/mcpclient/client_edges_test.go b/internal/mcpclient/client_edges_test.go index a631cc4..3e5b647 100644 --- a/internal/mcpclient/client_edges_test.go +++ b/internal/mcpclient/client_edges_test.go @@ -91,11 +91,39 @@ var _ = Describe("MCP remote boundaries", func() { _, err := client.Discover() Expect(err).NotTo(HaveOccurred()) _, err = client.Call("echo", nil) - Expect(err).To(MatchError(ContainSubstring("mcp_protocol_error"))) + Expect(err).To(MatchError(ContainSubstring("mcp_rpc_error"))) Expect(client.ResultKnown()).To(BeTrue()) Expect(err.Error()).NotTo(ContainSubstring("secret-error")) Expect(f.calls).To(Equal(1)) }) + DescribeTable("closes the session after a failed call without replaying it", func(rpc bool) { + f := fixture() + if rpc { + f.rpcError = true + } else { + f.callStatus = http.StatusServiceUnavailable + } + client := open(f) + _, err := client.Call("echo", nil) + Expect(err).To(HaveOccurred()) + client.Close() + // Cleanup responses must not replace the failed operation's diagnostics. + Expect(client.Headers()["Content-Type"]).To(Equal("application/json")) + f.mu.Lock() + defer f.mu.Unlock() + Expect(f.methods).To(ContainElement("DELETE")) + Expect(f.calls).To(Equal(1)) + Expect(f.headers[len(f.headers)-1].Get("Mcp-Session-Id")).To(Equal("fixture-session")) + }, Entry("RPC error", true), Entry("HTTP error", false)) + It("accepts a null nextCursor as the final page", func() { + f := fixture() + f.list = strings.TrimSuffix(f.list, "}") + `,"nextCursor":null}` + client := open(f) + tools, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + Expect(tools).To(HaveLen(1)) + Expect(f.listCount).To(Equal(1)) + }) DescribeTable("keeps malformed JSON-RPC errors unknown", func(raw string) { f := fixture() f.rawError = raw @@ -298,6 +326,7 @@ var _ = Describe("MCP remote boundaries", func() { "Authorization": []string{"Bearer secret"}, "X-Bkapi-Authorization": []string{"secret"}, "Set-Cookie": []string{"secret"}, + "mCp-SeSsIoN-iD": []string{"live-session"}, "Content-Type": []string{"application/json"}, }, http.Header{"X-Api-Key": []string{""}}, @@ -306,6 +335,7 @@ var _ = Describe("MCP remote boundaries", func() { Expect(masked["Authorization"]).To(Equal("***")) Expect(masked["X-Bkapi-Authorization"]).To(Equal("***")) Expect(masked["Set-Cookie"]).To(Equal("***")) + Expect(masked["mCp-SeSsIoN-iD"]).To(Equal("***")) Expect(masked["Content-Type"]).To(Equal("application/json")) }) It("enforces exact JSON and SSE read limits without success truncation", func() { diff --git a/internal/mcpclient/config.go b/internal/mcpclient/config.go index 7fb8393..0bbe455 100644 --- a/internal/mcpclient/config.go +++ b/internal/mcpclient/config.go @@ -19,6 +19,8 @@ package mcpclient import ( + "errors" + "fmt" "net/http" "net/url" "os" @@ -104,45 +106,65 @@ func LoadConfig(path string) (*Config, error) { return loaded, nil } +// configFieldError includes only a validated server name and static field/reason +// strings. Never pass raw parser messages, unknown keys or configuration values. +func configFieldError(name, field, reason string) error { + return &Error{ + "invalid_mcp_config", + fmt.Sprintf("Invalid MCP configuration: server %q, field %s: %s", name, field, reason), + } +} + func parseServer(name string, raw any) (*Server, error) { if name == "" || strings.HasPrefix(name, "-") || strings.ContainsFunc(name, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) { - return nil, configError() + return nil, &Error{ + "invalid_mcp_config", + "Invalid MCP configuration: mcpServers contains an invalid server name", + } } object, ok := raw.(map[string]any) if !ok { - return nil, configError() + return nil, configFieldError(name, "entry", "expected an object") + } + transport, ok := object["type"].(string) + if !ok || transport != "http" { + return nil, configFieldError( + name, + "type", + `must be explicitly set to "http"; other transports are unsupported`, + ) } for key := range object { if key != "type" && key != "url" && key != "headers" { - return nil, configError() + return nil, configFieldError(name, "entry", "only type, url and headers are supported") } } - transport, ok := object["type"].(string) - if !ok || transport != "http" { - return nil, configError() - } endpoint, ok := object["url"].(string) if !ok || !validEndpoint(endpoint) { - return nil, configError() + return nil, configFieldError( + name, + "url", + "expected an absolute HTTP(S) URL without userinfo or fragment", + ) } server := &Server{Name: name, URL: endpoint, Headers: make(map[string]string)} if rawHeaders, exists := object["headers"]; exists { headers, ok := rawHeaders.(map[string]any) if !ok { - return nil, configError() + return nil, configFieldError(name, "headers", "expected an object of string values") } for key, value := range headers { text, ok := value.(string) if !ok { - return nil, configError() + return nil, configFieldError(name, "headers", "expected string values") } server.Headers[key] = text } } if err := validateHeaders(server.Headers); err != nil { - return nil, err + return nil, configFieldError(name, "headers", "invalid, duplicate or protocol-managed header") } return server, nil } @@ -199,6 +221,10 @@ func (s Server) ResolveHeaders() (http.Header, error) { headers.Set(name, value) } if err := validateAuth(headers); err != nil { + var detail *Error + if errors.As(err, &detail) { + return nil, &Error{detail.Code, fmt.Sprintf("MCP server %q: %s", s.Name, detail.Message)} + } return nil, err } return headers, nil diff --git a/internal/mcpclient/config_test.go b/internal/mcpclient/config_test.go index 06a89cf..a89db2a 100644 --- a/internal/mcpclient/config_test.go +++ b/internal/mcpclient/config_test.go @@ -113,6 +113,24 @@ var _ = Describe("MCP configuration", func() { `{"mcpServers":{"test":{"type":"http","url":"https://example.com","headers":{"X-Key":"SECRET\n"}}}}`, ), ) + DescribeTable("identifies the invalid server and field without exposing values", func(entry, field string) { + _, err := LoadConfig(write(`{"mcpServers":{"broken":` + entry + `}}`)) + Expect( + err, + ).To( + MatchError( + And( + ContainSubstring("invalid_mcp_config"), + ContainSubstring(`"broken"`), + ContainSubstring(field), + ), + ), + ) + Expect(err.Error()).NotTo(ContainSubstring("SECRET")) + }, Entry("missing transport", `{"url":"https://example.com"}`, "type"), + Entry("stdio", `{"type":"stdio","url":"https://example.com"}`, "type"), + Entry("bad URL", `{"type":"http","url":"SECRET"}`, "url"), + Entry("bad headers", `{"type":"http","url":"https://example.com","headers":{"X-Key":42}}`, "headers")) It("uses explicit files, exact names, and safe errors", func() { path := write(`{"mcpServers":{"Case":{"type":"http","url":"http://example.com"}}}`) GinkgoT().Setenv("BK_CLI_CONFIG_DIR", filepath.Join(dir, "unused")) @@ -199,47 +217,47 @@ var _ = Describe("Server header resolution", func() { }, "mcp_auth_conflict", ), - Entry("empty", map[string]string{"Authorization": ""}, "invalid_mcp_config"), - Entry("empty bearer", map[string]string{"Authorization": "bEaReR "}, "invalid_mcp_config"), + Entry("empty", map[string]string{"Authorization": ""}, "invalid_mcp_auth"), + Entry("empty bearer", map[string]string{"Authorization": "bEaReR "}, "invalid_mcp_auth"), Entry( "bearer with spaces", map[string]string{"Authorization": "Bearer SECRET bad"}, - "invalid_mcp_config", + "invalid_mcp_auth", ), - Entry("non-object", map[string]string{"X-Bkapi-Authorization": `[]`}, "invalid_mcp_config"), - Entry("invalid JSON", map[string]string{"X-Bkapi-Authorization": `SECRET`}, "invalid_mcp_config"), - Entry("empty object", map[string]string{"X-Bkapi-Authorization": `{}`}, "invalid_mcp_config"), + Entry("non-object", map[string]string{"X-Bkapi-Authorization": `[]`}, "invalid_mcp_auth"), + Entry("invalid JSON", map[string]string{"X-Bkapi-Authorization": `SECRET`}, "invalid_mcp_auth"), + Entry("empty object", map[string]string{"X-Bkapi-Authorization": `{}`}, "invalid_mcp_auth"), Entry( "wrong type", map[string]string{"X-Bkapi-Authorization": `{"access_token":42}`}, - "invalid_mcp_config", + "invalid_mcp_auth", ), Entry( "empty token", map[string]string{"X-Bkapi-Authorization": `{"access_token":" "}`}, - "invalid_mcp_config", + "invalid_mcp_auth", ), Entry( "mixed modes", map[string]string{"X-Bkapi-Authorization": `{"access_token":"SECRET","bk_app_code":"a"}`}, - "invalid_mcp_config", + "invalid_mcp_auth", ), Entry( "unknown field", map[string]string{"X-Bkapi-Authorization": `{"access_token":"SECRET","extra":"x"}`}, - "invalid_mcp_config", + "invalid_mcp_auth", ), Entry( "two user tokens", map[string]string{ "X-Bkapi-Authorization": `{"bk_app_code":"a","bk_app_secret":"SECRET","bk_ticket":"t","bk_token":"t"}`, }, - "invalid_mcp_config", + "invalid_mcp_auth", ), Entry( "missing secret", map[string]string{"X-Bkapi-Authorization": `{"bk_app_code":"a","bk_ticket":"t"}`}, - "invalid_mcp_config", + "invalid_mcp_auth", ), ) DescribeTable("preserves template-like text literally", func(value string) { @@ -260,6 +278,6 @@ var _ = Describe("Server header resolution", func() { It("does not turn a templated bearer value into a valid credential", func() { GinkgoT().Setenv("MCP_TEST_SECRET", "valid-token") _, err := (Server{Headers: map[string]string{"Authorization": "Bearer ${MCP_TEST_SECRET}"}}).ResolveHeaders() - Expect(err).To(MatchError(ContainSubstring("invalid_mcp_config"))) + Expect(err).To(MatchError(ContainSubstring("invalid_mcp_auth"))) }) }) diff --git a/internal/mcpclient/transport.go b/internal/mcpclient/transport.go index 5277921..8c30f28 100644 --- a/internal/mcpclient/transport.go +++ b/internal/mcpclient/transport.go @@ -41,6 +41,7 @@ type wireState struct { mu sync.Mutex failure *Error result map[string]any + rpcError map[string]any resultBytes int known bool reached bool @@ -54,6 +55,7 @@ func remoteError(code string) *Error { "mcp_permission_denied": "MCP permission denied; check server, tool and business permissions", "mcp_connection_error": "MCP connection failed; check the configured endpoint and TLS settings", "mcp_timeout": "MCP operation timed out or was cancelled", + "mcp_rpc_error": "The MCP server returned a JSON-RPC error", "mcp_protocol_error": "Invalid MCP response or protocol negotiation", "mcp_discovery_incomplete": "MCP tool discovery did not complete", "mcp_response_too_large": "MCP response or tool catalog exceeds the fixed size limit", @@ -77,6 +79,7 @@ func (s *wireState) begin() { defer s.mu.Unlock() s.failure = nil s.result = nil + s.rpcError = nil s.resultBytes = 0 s.known = false } @@ -118,8 +121,10 @@ func (s *wireState) observe(data []byte, method string, id json.RawMessage) (boo } s.mu.Lock() s.known = true + detail, _ := object["error"].(map[string]any) // checked by validRPCError above + s.rpcError = map[string]any{"code": detail["code"], "message": detail["message"]} s.mu.Unlock() - return true, s.fail("mcp_protocol_error") + return true, s.fail("mcp_rpc_error") } result, ok := object["result"].(map[string]any) if !ok { @@ -169,7 +174,8 @@ func (t *guardedTransport) RoundTrip(request *http.Request) (*http.Response, err t.state.mu.Lock() failure := t.state.failure t.state.mu.Unlock() - if failure != nil { + // A failed operation must not prevent bounded SDK session cleanup. + if failure != nil && request.Method != http.MethodDelete { return nil, failure } var rpc struct { @@ -215,7 +221,10 @@ func (t *guardedTransport) RoundTrip(request *http.Request) (*http.Response, err } t.state.mu.Lock() t.state.reached = true - t.state.headers = maskedResponseHeaders(response.Header, t.headers) + // SDK cleanup may run before the caller renders the failed operation. + if request.Method != http.MethodDelete { + t.state.headers = maskedResponseHeaders(response.Header, t.headers) + } t.state.mu.Unlock() if response.StatusCode < 200 || response.StatusCode >= 300 { data, readErr := io.ReadAll(io.LimitReader(response.Body, MaxResponseBytes+1)) @@ -264,7 +273,7 @@ func maskedResponseHeaders(headers, configured http.Header) map[string]string { value := strings.Join(values, ", ") if configured.Get(key) != "" || strings.EqualFold(key, "Authorization") || strings.EqualFold(key, "X-Bkapi-Authorization") || - strings.EqualFold(key, "Set-Cookie") { + strings.EqualFold(key, "Set-Cookie") || strings.EqualFold(key, "Mcp-Session-Id") { value = "***" } else { for name := range configured { diff --git a/skills/bk-cli-mcp/SKILL.md b/skills/bk-cli-mcp/SKILL.md index 43e22b3..29d7454 100644 --- a/skills/bk-cli-mcp/SKILL.md +++ b/skills/bk-cli-mcp/SKILL.md @@ -9,7 +9,7 @@ description: 当用户希望通过 bk-cli 连接远程 MCP server、检查连接 使用 `bk-cli mcp ` 连接配置中的 Streamable HTTP server。先确认用户授权调用的目标和工具;发现工具不意味着授权调用全部工具。有副作用的工具按用户的业务授权执行。 -默认配置为 `~/.bk-cli/mcp.json`,或 `BK_CLI_CONFIG_DIR` 下的 `mcp.json`;`--config ` 只选一个指定文件,不合并。配置必须是严格 JSON: +默认配置为 `~/.bk-cli/mcp.json`,或 `BK_CLI_CONFIG_DIR` 下的 `mcp.json`;`--config ` 只选一个指定文件,不合并。配置必须是严格 JSON,每个 server 都必须显式填写 `"type": "http"`。从其他客户端迁移时,应另存一份只含 HTTP 服务的配置并补齐 type;不能直接使用含 stdio 条目的文件: ```json { @@ -25,7 +25,7 @@ description: 当用户希望通过 bk-cli 连接远程 MCP server、检查连接 认证只来自所选 server 的 headers,配置字符串就是最终发送值,不支持环境变量展开或模板渲染;不读取 context、auth login 缓存、`.env` 或其他客户端配置。显式 `--context` 会报错,`--header/--stage/--tenant` 不支持。不要通过反复登录 bk-cli 解决 MCP 身份问题。 -除个人 Bearer 外,可将 `X-Bkapi-Authorization` 设为完整认证 JSON 字符串(配置中转义内层引号):应用 code/secret + ticket、应用 code/secret + token、或独立 access_token。不要同时配置两种认证头。无认证 server 可省略 headers,自定义 API key 也写在 headers 中。参见[用户指南](../../docs/user-guide.md#11-远程-mcp-工具)的完整示例。 +除个人 Bearer 外,可将 `X-Bkapi-Authorization` 设为完整认证 JSON 字符串(配置中转义内层引号):应用 code/secret + ticket、应用 code/secret + token、或独立 access_token。不支持仅含应用 code/secret 的组合。不要同时配置两种认证头。无认证 server 可省略 headers,自定义 API key 也写在 headers 中。参见[用户指南](../../docs/user-guide.md#11-远程-mcp-工具)的完整示例。 ## 推荐流程 @@ -63,13 +63,16 @@ server、tool 和字段名使用配置/发现返回的原名,不改写大小 - 只发送明确提供的参数,不依赖 Schema default 自动补值。 - body 是完整 arguments 对象,不是 HTTP/JSON-RPC 包装。body 覆盖全部具名参数,不合并;未知或重复 flag 仍报错。 - 复杂 Schema、保留名(如 `help/body/config`)或无法表示为 flag 的字段通过 body 原名传入。 -- `--list` 不接受 server/tool 或其他执行模式;`--check`、`--tools` 与工具调用互斥。离线 dry-run 不接受具名工具参数,也不证明远端 Schema、工具存在性或权限。 +- `--list` 不接受 server/tool 或其他执行模式;`--check`、`--tools` 与工具调用互斥。离线 dry-run 不接受具名工具参数或 `--help`,也不证明远端 Schema、工具存在性或权限。 - 默认总超时 60 秒,`--timeout 90s` 覆盖整次连接、所有分页和调用。`--verbose` 输出脱敏诊断;`--insecure` 只在明确需要时使用。 ## 结果判断与恢复 本地错误写 stderr、退出 1;远程失败写 stdout 的一个 `ok:false` envelope、退出 2。成功结果位于 `data.result`,其中 text 不自动解释为业务 JSON。`ok:true` 不证明异步任务完成或任意业务字段表示成功;check 成功也不证明所有工具均有权限。 +- `invalid_mcp_config`:按错误中的 server 和字段修正配置;任意条目结构不合规都会使整份文件校验失败。 +- `invalid_mcp_auth`:所选 server 的认证格式不受支持,按错误列出的字段组合修正;`--list` 只检查结构,成功不代表认证有效。 +- `mcp_rpc_error`:在 `data.rpc_error` 查看服务端 code/message,区分参数或工具错误与协议错误;不自动重发。 - `mcp_auth_required`:检查所选 server 配置中的 headers,不自动 OAuth 登录或更换身份。 - `mcp_permission_denied`:检查服务、工具与业务权限,不一律归因于票据过期。 - `mcp_tool_not_found` / `invalid_mcp_arguments`:本地校验失败,没有调用工具;重新查看工具帮助。 diff --git a/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml b/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml index a27662c..6af717d 100644 --- a/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml +++ b/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml @@ -29,3 +29,12 @@ steps: checks: - {path: error.code, equals: mcp_capability_unsupported} - {path: data.result.requestState, equals: pending} + - name: preserve a known RPC error without exposing the session header + command: [mcp, fixture, echo, --text, rpc-error] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: mcp_rpc_error} + - {path: data.rpc_error.code, equals: -32602} + - {path: data.rpc_error.message, equals: Unknown tool} + - {path: headers.Mcp-Session-Id, equals: '***'} diff --git a/tests/integration/cases/mcp/MCP-005-offline.yaml b/tests/integration/cases/mcp/MCP-005-offline.yaml index f3b89bf..967fa8d 100644 --- a/tests/integration/cases/mcp/MCP-005-offline.yaml +++ b/tests/integration/cases/mcp/MCP-005-offline.yaml @@ -23,3 +23,9 @@ steps: exit_code: 1 checks: - {path: error.code, equals: invalid_mcp_arguments} + - name: help cannot override offline dry run + command: [mcp, offline, unverified, --body, '{}', --dry-run, --help] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_mcp_arguments} diff --git a/tests/integration/mock_api/app.py b/tests/integration/mock_api/app.py index 8ee6629..6d2eda3 100644 --- a/tests/integration/mock_api/app.py +++ b/tests/integration/mock_api/app.py @@ -95,7 +95,7 @@ def mcp_endpoint(): tools = [{"name": name, "description": "Synthetic MCP tool", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}} for name in ("echo", "fail", "slow", "input")] scenario = request.headers.get("X-Mock-Scenario", "") cursor = rpc.get("params", {}).get("cursor") - result = {"tools": tools} + result = {"tools": tools, "nextCursor": None} if scenario == "MCP-004": result = {"tools": tools[2:] if cursor else tools[:2]} if not cursor: @@ -103,6 +103,10 @@ def mcp_endpoint(): elif method == "tools/call": params = rpc.get("params", {}) name = params.get("name") + if name == "echo" and params.get("arguments", {}).get("text") == "rpc-error": + response = jsonify({"jsonrpc": "2.0", "id": rpc["id"], "error": {"code": -32602, "message": "Unknown tool"}}) + response.headers["Mcp-Session-Id"] = "integration-private-session" + return response if name == "slow": time.sleep(0.3) if name == "input": From a5b82814a26a43c5f4e8b7f9804bdf7067c163ed Mon Sep 17 00:00:00 2001 From: wklken Date: Fri, 23 Oct 2026 12:02:25 +0800 Subject: [PATCH 5/5] fix(mcp): retain terminal responses and isolate transport diagnostics Flush SDK-compatible SSE events at EOF and count complete CRLF sequences. Reject implicit flag tokens as values before network access, classify raw results before SDK decoding errors, and preserve RPC errors in HTTP error responses. Separate auxiliary request state and align check-stage labels with connection errors. Distinguish HTTP failures and handle SIGTERM. Add regression and integration coverage for the verified review findings. --- cmd/mcp/command.go | 12 +- cmd/mcp/command_test.go | 34 +++ docs/design.md | 14 +- internal/mcpclient/client.go | 13 +- internal/mcpclient/client_edges_test.go | 107 ++++++++- internal/mcpclient/client_test.go | 11 +- internal/mcpclient/transport.go | 214 +++++++++++------- skills/bk-cli-mcp/SKILL.md | 4 +- .../cases/mcp/MCP-003-arguments-errors.yaml | 15 ++ .../cases/mcp/MCP-005-offline.yaml | 6 + tests/integration/mock_api/app.py | 6 +- 11 files changed, 340 insertions(+), 96 deletions(-) diff --git a/cmd/mcp/command.go b/cmd/mcp/command.go index 2443896..8a49e29 100644 --- a/cmd/mcp/command.go +++ b/cmd/mcp/command.go @@ -28,6 +28,7 @@ import ( "os" "os/signal" "strings" + "syscall" "time" "github.com/spf13/cobra" @@ -142,7 +143,7 @@ func (o *options) parse(cmd *cobra.Command, args []string) error { if flag.NoOptDefVal != "" { value = flag.NoOptDefVal } else { - if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + if i+1 >= len(args) || (strings.HasPrefix(args[i+1], "-") && args[i+1] != "-") { return invalidArguments() } i++ @@ -156,7 +157,7 @@ func (o *options) parse(cmd *cobra.Command, args []string) error { return &mcpclient.Error{Code: "unsupported_option", Message: "Option is not supported by MCP"} } if !hasValue { - if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + if i+1 >= len(args) || (strings.HasPrefix(args[i+1], "-") && args[i+1] != "-") { return invalidArguments() } i++ @@ -300,7 +301,7 @@ func (o *options) listServers(cmd *cobra.Command) error { } func (o *options) execute(cmd *cobra.Command, server *mcpclient.Server, headers http.Header) error { - interrupted, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + interrupted, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) defer stop() ctx, cancel := context.WithTimeout(interrupted, o.timeout) defer cancel() @@ -320,7 +321,10 @@ func (o *options) execute(cmd *cobra.Command, server *mcpclient.Server, headers } if err := client.Connect(); err != nil { checks["connection"] = "failed" - if client.Connected() { + var detail *mcpclient.Error + if client.Connected() && errors.As(err, &detail) && + (detail.Code == "mcp_protocol_error" || detail.Code == "mcp_capability_unsupported" || + detail.Code == "mcp_response_too_large") { checks["connection"] = "passed" checks["protocol"] = "failed" } diff --git a/cmd/mcp/command_test.go b/cmd/mcp/command_test.go index 9d0e313..243ab9a 100644 --- a/cmd/mcp/command_test.go +++ b/cmd/mcp/command_test.go @@ -51,6 +51,7 @@ var _ = Describe("MCP command", func() { var endpoint, dir string var result string var rpcError bool + var connectionStatus int var callDelay time.Duration var receivedHeaders chan http.Header run := func(args ...string) (string, string, error) { @@ -71,9 +72,14 @@ var _ = Describe("MCP command", func() { result = `{"content":[],"structuredContent":{"n":9007199254740993,"decimal":0.123456789012345678901}}` callDelay = 0 rpcError = false + connectionStatus = 0 receivedHeaders = make(chan http.Header, 64) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) + if connectionStatus != 0 { + w.WriteHeader(connectionStatus) + return + } receivedHeaders <- r.Header.Clone() if r.Method == "DELETE" { w.WriteHeader(204) @@ -131,6 +137,34 @@ var _ = Describe("MCP command", func() { endpoint = server.URL writeConfig(dir, endpoint) }) + DescribeTable("rejects flag tokens used as implicit values before contacting a server", func(args []string) { + out, stderr, err := run(args...) + Expect(err).To(HaveOccurred()) + Expect(out).To(BeEmpty()) + Expect(stderr).To(ContainSubstring("invalid_mcp_arguments")) + Expect(requests.Load()).To(BeZero()) + }, Entry("dynamic short help", []string{"test", "echo", "--text", "-h"}), + Entry("config short help", []string{"--config", "-h"}), + Entry("unknown short flag", []string{"test", "echo", "--text", "-x"})) + It("allows explicit dash-prefixed string values", func() { + _, _, err := run("test", "echo", "--text=-h") + Expect(err).NotTo(HaveOccurred()) + Expect(calls.Load()).To(Equal(int32(1))) + }) + DescribeTable("keeps check stages consistent with HTTP connection failures", func(status int, code string) { + connectionStatus = status + out, stderr, err := run("test", "--check") + Expect(err).To(HaveOccurred()) + Expect(stderr).To(BeEmpty()) + var env struct { + Error struct{ Code string } + Data struct{ Checks map[string]string } + } + Expect(json.Unmarshal([]byte(out), &env)).To(Succeed()) + Expect(env.Error.Code).To(Equal(code)) + Expect(env.Data.Checks["connection"]).To(Equal("failed")) + Expect(env.Data.Checks["protocol"]).To(Equal("not_run")) + }, Entry("unauthorized", 401, "mcp_auth_required"), Entry("forbidden", 403, "mcp_permission_denied"), Entry("redirect", 302, "mcp_connection_error"), Entry("server failure", 503, "mcp_http_error")) It("preserves RPC code and message in a known failure envelope", func() { rpcError = true out, stderr, err := run("test", "echo", "--text", "hello") diff --git a/docs/design.md b/docs/design.md index 0df79a6..b22c9a4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -267,9 +267,9 @@ flowchart TD - `ResolveHeaders` 校验所选 server 的认证形态,逐值原样复制 headers;配置值不做环境变量展开或模板渲染,`${...}`/`$${...}` 没有特殊含义。不读取凭据环境变量、执行 shell、加载 `.env` 或回写。根帮助不读配置,server 静态帮助只读配置结构;tool 帮助仅连接并发现,不读取 body/stdin 或执行工具。 - 支持 Bearer、应用 code/secret + ticket 或 token、独立 access_token、其他合法 scheme 和自定义头。拒绝两种认证头并存、空票据或非法蓝鲸 JSON。不支持仅含应用 code/secret 的组合;认证形态错误使用 `invalid_mcp_auth` 并说明 server 和支持格式,双认证头冲突仍为 `mcp_auth_conflict`。`--list` 成功只证明结构有效,不代表认证形态通过。所有实际 HTTP 请求使用同一份已解析 headers;不跟随重定向,默认校验 HTTPS 证书。 - 禁止配置覆盖 Host、内容类型、长度、Accept、连接控制、Last-Event-ID 和 `Mcp-*` 协议头。诊断中的全部配置 header 值固定为 `***`;响应同名头、已知认证头和 `Mcp-Session-Id` 也脱敏。原始工具业务结果不做通用内容 DLP。 -- `ReadArguments` 将内联 JSON、`@file`、stdin 解析为完整对象;拒绝重复字段、非对象、尾随内容、非法 UTF-8。数字保留为 `json.Number`。`BuildArguments` 拒绝重复/未知具名参数,body 完全覆盖已识别具名值,不合并、不注入 default。 +- `ReadArguments` 将内联 JSON、`@file`、stdin 解析为完整对象;拒绝重复字段、非对象、尾随内容、非法 UTF-8。数字保留为 `json.Number`。`BuildArguments` 拒绝重复/未知具名参数;参数值以 `-` 开头时使用 `--name=value`,独立 `-`(如 `--body -`)仍可作为值,避免把 `-h` 等选项吞作参数。body 完全覆盖已识别具名值,不合并、不注入 default。 - `Parameters` 映射可明确表示的 string/integer/number/boolean/array/object 字段;保留名、组合、引用和无法表示的名字仅用 body 原名传入。校验明确的类型、required、enum、嵌套 properties/items;数值 enum 精确比较。其他 JSON Schema 关键字交由服务端,不访问远程 `$ref`。 -- 显式 `--context` 报 `unsupported_option`。不支持 `--header/--stage/--tenant`;`--timeout` 默认 60 秒,覆盖连接、全部分页和调用的总预算,不读取 context。取消或超时结束本次会话。离线 dry-run 必须带完整 body,拒绝具名工具参数及与 `--help` 组合,零网络并标记未验证远端。 +- 显式 `--context` 报 `unsupported_option`。不支持 `--header/--stage/--tenant`;`--timeout` 默认 60 秒,覆盖连接、全部分页和调用的总预算,不读取 context。SIGINT、SIGTERM、调用方取消或超时结束本次会话。离线 dry-run 必须带完整 body,拒绝具名工具参数及与 `--help` 组合,零网络并标记未验证远端。 SDK 与数字保真(2026-10-22 验证): @@ -280,11 +280,11 @@ SDK 与数字保真(2026-10-22 验证): 生命周期和结果契约: - 测试覆盖 `2025-03-26`、`2025-06-18`、`2025-11-25`、`2026-07-28` 的 JSON/SSE 发现与调用。由 SDK 处理 `server/discover` 及旧协议初始化差异,不自行降级 transport。 -- 显式关闭 SDK 多轮输入、负载退避重发、独立 SSE 及重连;HTTP 请求移除 replay body,并统计/拒绝第二次 `tools/call`。发现和调用复用一次 client 生命周期,结束关闭会话和空闲连接。失败短路允许 SDK DELETE 清理通过,但仍受总超时/取消预算约束,不允许重发工具调用;清理响应不覆盖业务请求的响应头诊断。 +- 显式关闭 SDK 多轮输入、负载退避重发、独立 SSE 及重连;HTTP 请求移除 replay body,并统计/拒绝第二次 `tools/call`。发现和调用复用一次 client 生命周期,结束关闭会话和空闲连接。失败短路允许 SDK DELETE 清理通过,但仍受总超时/取消预算约束,不允许重发工具调用;清理、通知和服务端请求回复的 HTTP 响应/错误使用独立状态,不覆盖业务请求的响应头或错误。 - 分页直到 nextCursor 缺省、空字符串或 null,拒绝循环游标、重名工具、畸形目录;任何中途错误不得输出完整成功。空 tools 列表成功。目录不持久缓存。 -- `--check` 输出 config/connection/protocol/tools 阶段以及 `complete`;发现通过只表示该请求被接受,不证明 token 或所有业务权限有效。`--tools` 保留完整描述和元数据。 -- 本地输入/配置/参数错误走 stderr、退出 1,即使参数错误发生在发现之后。远程错误走一个 stdout 失败 envelope、退出 2;401/403 分别为 `mcp_auth_required`/`mcp_permission_denied`,不换身份或重试。 -- `tools/list` 和 `tools/call` 的合法 JSON-RPC error 使用 `mcp_rpc_error`,在 `data.rpc_error` 保留服务端 code/message(与工具结果一样不做通用内容 DLP);安全诊断不包含这段远端文本。合法调用错误属于已知结果,不标记 unknown;畸形错误仍为 `mcp_protocol_error`。 +- `--check` 输出 config/connection/protocol/tools 阶段以及 `complete`;连接失败、重定向和 HTTP 拒绝时 connection 为 failed、protocol 为 not_run,成功 HTTP 响应中的协议问题才标记 protocol 为 failed;发现通过只表示该请求被接受,不证明 token 或所有业务权限有效。`--tools` 保留完整描述和元数据。 +- 本地输入/配置/参数错误走 stderr、退出 1,即使参数错误发生在发现之后。远程错误走一个 stdout 失败 envelope、退出 2;401/403 分别为 `mcp_auth_required`/`mcp_permission_denied`,不换身份或重试。其他非 2xx HTTP 状态在没有可用 RPC 错误时为 `mcp_http_error`,与协议报文错误区分。 +- `tools/list` 和 `tools/call` 的合法 JSON-RPC error 使用 `mcp_rpc_error`,包括非 2xx 响应中的合法 RPC 错误(401/403 和重定向仍优先使用 HTTP 分类),在 `data.rpc_error` 保留服务端 code/message(与工具结果一样不做通用内容 DLP);安全诊断不包含这段远端文本。合法调用错误属于已知结果,不标记 unknown;完整且可判定的原始工具结果先分类,再处理 SDK 解码错误,SDK 不支持的 content 类型保留原始结果并报协议错误,不标记 unknown;`requestState` 等异步/输入接续字段按不支持能力处理;畸形错误仍为 `mcp_protocol_error`。 - 调用保留 `data.server/tool/result`。`isError:true` 为 `mcp_tool_error`;input_required、sampling、elicitation 或 task 状态为 `mcp_capability_unsupported`,保留可用原始要求/标识,不接续、不伪报完成。 - 调用已尝试但没有完整可判定结果时设 `data.execution_state: "unknown"`,保留实际错误码,提示先业务查询再决定重试。完整结果后的 jq/输出错误不改为 unknown,不重发。`ok:true` 不解释业务 JSON 的 code/result/status,不证明异步任务完成。 - jq 在本地解析阶段编译,只对聚合/分类后的最终 envelope 执行一次;失败 envelope 不被投影隐藏,投影失败保留原结果。 @@ -299,6 +299,8 @@ SDK 与数字保真(2026-10-22 验证): | 累计工具目录 | 原始分页 JSON 报文累计 32 MiB;`mcp_response_too_large` | | 发现页数 | 最多 100 页;仍有 nextCursor 时 `mcp_discovery_incomplete` | +SSE 观察与固定 SDK 保持一致:EOF 会提交待处理事件,CRLF 的两个字节都计入事件上限。非 SSE JSON 响应若只有通知/反向请求、缺少终结结果,立即报协议错误。 + 远端 JSON 同样拒绝重复 key、非法 UTF-8 和超过 128 层的嵌套。限制不截断后返回成功;调用结果超限且未取得完整结果按 unknown 处理。上限容纳常规 Schema/结果并限制单次 CLI 的内存和发现成本,不提供调节 flags。 验收入口:`internal/mcpclient/*_test.go` 覆盖配置/认证/精度/协议/分页/大小限制/单次调用,`cmd/mcp/*_test.go` 与 `cmd/mcp_test.go` 覆盖模式、输出、帮助、context 隔离和 jq;`tests/integration/cases/mcp/` 的 MCP-001 至 MCP-005 覆盖构建后 CLI。真实环境验收清单见 `tests/integration/README.md`,mock 通过不代表真实服务已联调。 diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go index 0db0da4..bdeb89f 100644 --- a/internal/mcpclient/client.go +++ b/internal/mcpclient/client.go @@ -89,7 +89,7 @@ func (c *Client) Close() { c.transport.base.CloseIdleConnections() } -// Connected reports whether any target HTTP response was received. +// Connected reports whether the last operation received a successful HTTP status. func (c *Client) Connected() bool { c.state.mu.Lock() defer c.state.mu.Unlock() @@ -212,10 +212,10 @@ func (c *Client) Call(name string, arguments map[string]any) (map[string]any, er c.state.begin() _, err := c.session.CallTool(c.ctx, &mcp.CallToolParams{Name: name, Arguments: arguments}) result, _ := c.result() - if err != nil { - return result, c.classify(err) - } if result == nil { + if err != nil { + return nil, c.classify(err) + } return nil, remoteError("mcp_protocol_error") } if unsupportedResult(result) { @@ -236,6 +236,9 @@ func (c *Client) Call(name string, arguments map[string]any) (map[string]any, er } } c.markKnown() + if err != nil { + return result, c.classify(err) + } return result, nil } @@ -245,7 +248,7 @@ func unsupportedResult(result map[string]any) bool { if kind, ok := result["resultType"].(string); ok && kind != "" && kind != "complete" { return true } - for _, key := range []string{"task", "taskId", "inputRequests", "unsupported_request"} { + for _, key := range []string{"task", "taskId", "inputRequests", "requestState", "unsupported_request"} { if _, ok := result[key]; ok { return true } diff --git a/internal/mcpclient/client_edges_test.go b/internal/mcpclient/client_edges_test.go index 3e5b647..4151255 100644 --- a/internal/mcpclient/client_edges_test.go +++ b/internal/mcpclient/client_edges_test.go @@ -44,6 +44,111 @@ var _ = Describe("MCP remote boundaries", func() { Expect(client.Connect()).To(Succeed()) return client } + DescribeTable("retains SDK accepted SSE events at EOF", func(ending string) { + f := fixture() + f.sse = true + f.sseEnding = &ending + client := open(f) + tools, err := client.Discover() + Expect(err).NotTo(HaveOccurred()) + Expect(tools).To(HaveLen(1)) + result, err := client.Call("echo", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(result["structuredContent"]).To(Equal(map[string]any{"n": json.Number("9007199254740993")})) + Expect(client.ResultKnown()).To(BeTrue()) + Expect(f.calls).To(Equal(1)) + }, Entry("no ending", ""), Entry("LF", "\n"), Entry("CR", "\r"), Entry("CRLF", "\r\n")) + DescribeTable("keeps complete raw outcomes known when SDK decoding fails", func(raw, code string) { + f := fixture() + f.result = raw + client := open(f) + result, err := client.Call("echo", nil) + Expect(err).To(MatchError(ContainSubstring(code))) + Expect(result).NotTo(BeNil()) + Expect(client.ResultKnown()).To(BeTrue()) + Expect(f.calls).To(Equal(1)) + }, Entry("input requests", `{"resultType":"input_required","inputRequests":{"x":{"type":"future"}}}`, "mcp_capability_unsupported"), + Entry("unknown content", `{"content":[{"type":"future"}]}`, "mcp_protocol_error"), + Entry("request state", `{"content":[],"requestState":"pending"}`, "mcp_capability_unsupported")) + It("preserves RPC errors in non-success HTTP responses", func() { + f := fixture() + f.rpcError = true + f.responseStatus = http.StatusBadRequest + client := open(f) + _, err := client.Call("echo", nil) + Expect(err).To(MatchError(ContainSubstring("mcp_rpc_error"))) + Expect( + client.RPCError(), + ).To( + Equal(map[string]any{"code": json.Number("-32602"), "message": "secret-error"}), + ) + Expect(client.ResultKnown()).To(BeTrue()) + Expect(f.calls).To(Equal(1)) + }) + DescribeTable("rejects a non-SSE response without a matching result immediately", func(raw string) { + state := &wireState{} + body := &observedBody{ + source: io.NopCloser(strings.NewReader(raw)), + state: state, + method: "tools/call", + id: json.RawMessage("1"), + } + _, err := io.ReadAll(body) + Expect(err).To(MatchError(ContainSubstring("mcp_protocol_error"))) + Expect(state.known).To(BeFalse()) + }, Entry("notification", `{"jsonrpc":"2.0","method":"notifications/message","params":{}}`), + Entry("server request", `{"jsonrpc":"2.0","id":"server","method":"roots/list"}`)) + It("counts both bytes of CRLF at the event size boundary", func() { + for _, extra := range []int{0, 1} { + prefix := "data: " + `{"jsonrpc":"2.0","id":1,"result":{"content":[]}}` + payload := prefix + strings.Repeat(" ", MaxResponseBytes-len(prefix)-4+extra) + "\r\n\r\n" + body := &observedBody{ + source: io.NopCloser(strings.NewReader(payload)), + state: &wireState{}, + method: "tools/call", + id: json.RawMessage("1"), + sse: true, + } + _, err := io.Copy(io.Discard, body) + if extra == 0 { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(MatchError(ContainSubstring("mcp_response_too_large"))) + } + } + }) + DescribeTable("keeps auxiliary responses and failures out of operation state", func(method string, status int) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Diagnostic", "auxiliary") + w.WriteHeader(status) + })) + DeferCleanup(server.Close) + state := &wireState{headers: map[string]string{"X-Diagnostic": "operation"}} + transport := &guardedTransport{ + base: http.DefaultTransport.(*http.Transport).Clone(), + ctx: context.Background(), + state: state, + } + DeferCleanup(transport.base.CloseIdleConnections) + var request *http.Request + var err error + if method == "DELETE" { + request, err = http.NewRequest(method, server.URL, nil) + } else { + request, err = http.NewRequest( + "POST", + server.URL, + strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`), + ) + } + Expect(err).NotTo(HaveOccurred()) + response, _ := transport.RoundTrip(request) + if response != nil { + Expect(response.Body.Close()).To(Succeed()) + } + Expect(state.headers).To(Equal(map[string]string{"X-Diagnostic": "operation"})) + Expect(state.failure).To(BeNil()) + }, Entry("notification response", "notification", 202), Entry("notification error", "notification", 503), Entry("cleanup error", "DELETE", 503)) DescribeTable( "preserves explicit failure state", func(raw, code string, known bool) { @@ -173,7 +278,7 @@ var _ = Describe("MCP remote boundaries", func() { _, err = client.Call("echo", nil) Expect(err).To(MatchError(ContainSubstring(code))) Expect(f.calls).To(Equal(1)) - }, Entry("unauthorized", 401, "mcp_auth_required"), Entry("forbidden", 403, "mcp_permission_denied"), Entry("load shedding", 503, "mcp_protocol_error")) + }, Entry("unauthorized", 401, "mcp_auth_required"), Entry("forbidden", 403, "mcp_permission_denied"), Entry("load shedding", 503, "mcp_http_error"), Entry("rate limited", 429, "mcp_http_error"), Entry("missing endpoint", 404, "mcp_http_error")) It("reports unsupported sampling without interactive handling or resending", func() { f := fixture() f.prefix = "data: {\"jsonrpc\":\"2.0\",\"id\":\"server-1\",\"method\":\"sampling/createMessage\",\"params\":{}}\n\n" diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go index 2495181..3b7186e 100644 --- a/internal/mcpclient/client_test.go +++ b/internal/mcpclient/client_test.go @@ -52,6 +52,8 @@ type protocolFixture struct { rawError string eventPrefix string duplicateResult bool + sseEnding *string + responseStatus int } func (f *protocolFixture) serve(w http.ResponseWriter, r *http.Request) { @@ -146,9 +148,16 @@ func (f *protocolFixture) serve(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, f.prefix) return } + if f.responseStatus != 0 && request.Method == "tools/call" { + w.WriteHeader(f.responseStatus) + } if f.sse { w.Header().Set("Content-Type", "text/event-stream") - fmt.Fprintf(w, "%sevent: message\r\ndata: %s\r\n\r\n", f.eventPrefix, message) + ending := "\r\n\r\n" + if f.sseEnding != nil { + ending = *f.sseEnding + } + fmt.Fprintf(w, "%sevent: message\r\ndata: %s%s", f.eventPrefix, message, ending) if f.duplicateResult { fmt.Fprintf( w, diff --git a/internal/mcpclient/transport.go b/internal/mcpclient/transport.go index 8c30f28..a3fe771 100644 --- a/internal/mcpclient/transport.go +++ b/internal/mcpclient/transport.go @@ -55,6 +55,7 @@ func remoteError(code string) *Error { "mcp_permission_denied": "MCP permission denied; check server, tool and business permissions", "mcp_connection_error": "MCP connection failed; check the configured endpoint and TLS settings", "mcp_timeout": "MCP operation timed out or was cancelled", + "mcp_http_error": "The MCP endpoint returned an unsuccessful HTTP status", "mcp_rpc_error": "The MCP server returned a JSON-RPC error", "mcp_protocol_error": "Invalid MCP response or protocol negotiation", "mcp_discovery_incomplete": "MCP tool discovery did not complete", @@ -168,16 +169,7 @@ type guardedTransport struct { // RoundTrip applies one target's headers and enforces no-replay and read limits. func (t *guardedTransport) RoundTrip(request *http.Request) (*http.Response, error) { - if t.ctx.Err() != nil { - return nil, t.state.fail("mcp_timeout") - } - t.state.mu.Lock() - failure := t.state.failure - t.state.mu.Unlock() - // A failed operation must not prevent bounded SDK session cleanup. - if failure != nil && request.Method != http.MethodDelete { - return nil, failure - } + state := t.state var rpc struct { Method string `json:"method"` ID json.RawMessage `json:"id"` @@ -185,21 +177,37 @@ func (t *guardedTransport) RoundTrip(request *http.Request) (*http.Response, err if request.GetBody != nil { body, err := request.GetBody() if err != nil { - return nil, t.state.fail("mcp_protocol_error") + return nil, state.fail("mcp_protocol_error") } err = json.NewDecoder(body).Decode(&rpc) _ = body.Close() if err != nil { - return nil, t.state.fail("mcp_protocol_error") + return nil, state.fail("mcp_protocol_error") } } + // Cleanup, notifications and replies have private diagnostics so they + // cannot overwrite the current operation's headers or failure. + if rpc.Method != "server/discover" && rpc.Method != "initialize" && + rpc.Method != "tools/list" && rpc.Method != "tools/call" { + state = &wireState{} + } + if t.ctx.Err() != nil { + return nil, state.fail("mcp_timeout") + } + state.mu.Lock() + failure := state.failure + state.mu.Unlock() + // A failed operation must not prevent bounded SDK session cleanup. + if failure != nil && request.Method != http.MethodDelete { + return nil, failure + } if rpc.Method == "tools/call" { - t.state.mu.Lock() - t.state.calls++ - calls := t.state.calls - t.state.mu.Unlock() + state.mu.Lock() + state.calls++ + calls := state.calls + state.mu.Unlock() if calls > 1 { - return nil, t.state.fail("mcp_protocol_error") + return nil, state.fail("mcp_protocol_error") } } requestContext, cancel := context.WithCancel(request.Context()) @@ -215,50 +223,25 @@ func (t *guardedTransport) RoundTrip(request *http.Request) (*http.Response, err if err != nil { release() if request.Context().Err() != nil || t.ctx.Err() != nil { - return nil, t.state.fail("mcp_timeout") + return nil, state.fail("mcp_timeout") } - return nil, t.state.fail("mcp_connection_error") + return nil, state.fail("mcp_connection_error") } - t.state.mu.Lock() - t.state.reached = true + state.mu.Lock() + state.reached = response.StatusCode >= 200 && response.StatusCode < 300 // SDK cleanup may run before the caller renders the failed operation. if request.Method != http.MethodDelete { - t.state.headers = maskedResponseHeaders(response.Header, t.headers) + state.headers = maskedResponseHeaders(response.Header, t.headers) } - t.state.mu.Unlock() + state.mu.Unlock() if response.StatusCode < 200 || response.StatusCode >= 300 { - data, readErr := io.ReadAll(io.LimitReader(response.Body, MaxResponseBytes+1)) - _ = response.Body.Close() - release() - if len(data) > MaxResponseBytes { - return nil, t.state.fail("mcp_response_too_large") - } - if readErr != nil { - return nil, t.state.fail("mcp_connection_error") - } - // The SDK owns the specified server/discover -> initialize compatibility fallback. - if rpc.Method == "server/discover" && - (response.StatusCode == http.StatusBadRequest || response.StatusCode == http.StatusNotFound || - response.StatusCode == http.StatusMethodNotAllowed) { - response.Body = io.NopCloser(bytes.NewReader(data)) - return response, nil - } - code := "mcp_protocol_error" - switch response.StatusCode { - case http.StatusUnauthorized: - code = "mcp_auth_required" - case http.StatusForbidden: - code = "mcp_permission_denied" - } - if response.StatusCode >= 300 && response.StatusCode < 400 { - code = "mcp_connection_error" - } - return nil, t.state.fail(code) + defer release() + return state.handleHTTPFailure(response, rpc.Method, rpc.ID) } sse := strings.HasPrefix(strings.ToLower(response.Header.Get("Content-Type")), "text/event-stream") response.Body = &observedBody{ source: response.Body, - state: t.state, + state: state, method: rpc.Method, id: rpc.ID, sse: sse, @@ -267,6 +250,51 @@ func (t *guardedTransport) RoundTrip(request *http.Request) (*http.Response, err return response, nil } +// handleHTTPFailure bounds error bodies and preserves RPC errors without changing +// authentication, redirect or SDK discovery-fallback precedence. +func (s *wireState) handleHTTPFailure( + response *http.Response, + method string, + id json.RawMessage, +) (*http.Response, error) { + data, readErr := io.ReadAll(io.LimitReader(response.Body, MaxResponseBytes+1)) + _ = response.Body.Close() + if len(data) > MaxResponseBytes { + return nil, s.fail("mcp_response_too_large") + } + if readErr != nil { + return nil, s.fail("mcp_connection_error") + } + // The SDK owns the specified server/discover -> initialize compatibility fallback. + if method == "server/discover" && + (response.StatusCode == http.StatusBadRequest || response.StatusCode == http.StatusNotFound || + response.StatusCode == http.StatusMethodNotAllowed) { + response.Body = io.NopCloser(bytes.NewReader(data)) + return response, nil + } + code := "mcp_http_error" + switch response.StatusCode { + case http.StatusUnauthorized: + code = "mcp_auth_required" + case http.StatusForbidden: + code = "mcp_permission_denied" + } + if response.StatusCode >= 300 && response.StatusCode < 400 { + code = "mcp_connection_error" + } + if code == "mcp_http_error" && (method == "tools/list" || method == "tools/call") { + // HTTP status errors may still contain a valid JSON-RPC error. Keep + // HTTP auth/permission/redirect classifications authoritative. + raw, decodeErr := decodeJSON(bytes.NewReader(data)) + object, ok := raw.(map[string]any) + if decodeErr == nil && ok && validRPCError(object) { + _, rpcErr := s.observe(data, method, id) + return nil, rpcErr + } + } + return nil, s.fail(code) +} + func maskedResponseHeaders(headers, configured http.Header) map[string]string { result := make(map[string]string, len(headers)) for key, values := range headers { @@ -324,64 +352,96 @@ func (b *observedBody) Read(p []byte) (int, error) { return 0, observeErr } } + if errors.Is(err, io.EOF) && !b.terminal { + if b.afterCR || len(b.line) > 0 { + b.afterCR = false + if observeErr := b.observeLine(); observeErr != nil { + return 0, observeErr + } + } + if observeErr := b.dispatchEvent(); observeErr != nil { + return 0, observeErr + } + } } else { if len(b.data)+n > MaxResponseBytes { return 0, b.state.fail("mcp_response_too_large") } b.data = append(b.data, p[:n]...) if errors.Is(err, io.EOF) && len(bytes.TrimSpace(b.data)) > 0 { - if _, observeErr := b.state.observe(b.data, b.method, b.id); observeErr != nil { + terminal, observeErr := b.state.observe(b.data, b.method, b.id) + if observeErr != nil { return 0, observeErr } + if !terminal { + return 0, b.state.fail("mcp_protocol_error") + } } } return n, err } func (b *observedBody) observeByte(char byte) error { - // The SDK ends this request at its first matching response. Later frames - // may be drained, but must not overwrite this or the next request's state. + // Delay a CR terminator until the next byte so a CRLF pair is counted + // together before dispatching/resetting the event budget. if b.terminal { return nil } - if b.afterCR && char == '\n' { + if b.afterCR { b.afterCR = false - return nil + if char != '\n' { + if err := b.observeLine(); err != nil || b.terminal { + return err + } + } } - b.afterCR = char == '\r' b.eventBytes++ if b.eventBytes > MaxResponseBytes { return b.state.fail("mcp_response_too_large") } - if char != '\r' && char != '\n' { - b.line = append(b.line, char) + if char == '\r' { + b.afterCR = true return nil } + if char == '\n' { + return b.observeLine() + } + b.line = append(b.line, char) + return nil +} + +func (b *observedBody) observeLine() error { line := b.line b.line = nil if len(line) == 0 { - data := bytes.TrimSuffix(b.data, []byte{'\n'}) - if len(data) > 0 && (b.eventName == "" || b.eventName == "message") { - terminal, err := b.state.observe(data, b.method, b.id) - b.terminal = terminal - if err != nil { - return err - } - } - b.data = nil - b.eventBytes = 0 - b.eventName = "" - return nil + return b.dispatchEvent() } - if value, ok := bytes.CutPrefix(line, []byte("data:")); ok { - value = bytes.TrimPrefix(value, []byte{' '}) + field, value, _ := bytes.Cut(line, []byte{':'}) + value = bytes.TrimPrefix(value, []byte{' '}) + switch string(field) { + case "data": b.data = append(b.data, value...) b.data = append(b.data, '\n') + case "event": + b.eventName = string(value) + } + return nil +} + +func (b *observedBody) dispatchEvent() error { + if b.terminal { + return nil } - if value, ok := bytes.CutPrefix(line, []byte("event:")); ok { - b.eventName = string(bytes.TrimPrefix(value, []byte{' '})) - } else if bytes.Equal(line, []byte("event")) { - b.eventName = "" + data := bytes.TrimSuffix(b.data, []byte{'\n'}) + if len(data) > 0 && (b.eventName == "" || b.eventName == "message") { + terminal, err := b.state.observe(data, b.method, b.id) + b.terminal = terminal + if err != nil { + return err + } } + b.data = nil + b.eventBytes = 0 + b.eventName = "" return nil } diff --git a/skills/bk-cli-mcp/SKILL.md b/skills/bk-cli-mcp/SKILL.md index 29d7454..bad4787 100644 --- a/skills/bk-cli-mcp/SKILL.md +++ b/skills/bk-cli-mcp/SKILL.md @@ -59,12 +59,13 @@ bk-cli mcp demo search --body @params.json --dry-run server、tool 和字段名使用配置/发现返回的原名,不改写大小写、下划线或点号。未知工具应先看完整目录,不猜名称。工具帮助联网但不执行工具或读取 stdin。 +- 以 `-` 开头的参数值使用 `--name=value`(如 `--text=-h`、`--limit=-1`),不要把短选项放在参数值位置;`--body -` 仍读取 stdin。 - 字符串保持字符串;布尔值显式使用 `=true/false`,数组和对象使用 JSON。 - 只发送明确提供的参数,不依赖 Schema default 自动补值。 - body 是完整 arguments 对象,不是 HTTP/JSON-RPC 包装。body 覆盖全部具名参数,不合并;未知或重复 flag 仍报错。 - 复杂 Schema、保留名(如 `help/body/config`)或无法表示为 flag 的字段通过 body 原名传入。 - `--list` 不接受 server/tool 或其他执行模式;`--check`、`--tools` 与工具调用互斥。离线 dry-run 不接受具名工具参数或 `--help`,也不证明远端 Schema、工具存在性或权限。 -- 默认总超时 60 秒,`--timeout 90s` 覆盖整次连接、所有分页和调用。`--verbose` 输出脱敏诊断;`--insecure` 只在明确需要时使用。 +- 默认总超时 60 秒,`--timeout 90s` 覆盖整次连接、所有分页和调用;SIGINT/SIGTERM 会取消本次操作。`--verbose` 输出脱敏诊断;`--insecure` 只在明确需要时使用。 ## 结果判断与恢复 @@ -73,6 +74,7 @@ server、tool 和字段名使用配置/发现返回的原名,不改写大小 - `invalid_mcp_config`:按错误中的 server 和字段修正配置;任意条目结构不合规都会使整份文件校验失败。 - `invalid_mcp_auth`:所选 server 的认证格式不受支持,按错误列出的字段组合修正;`--list` 只检查结构,成功不代表认证有效。 - `mcp_rpc_error`:在 `data.rpc_error` 查看服务端 code/message,区分参数或工具错误与协议错误;不自动重发。 +- `mcp_http_error`:服务端返回非成功 HTTP 状态,且没有可用的 RPC 错误;检查 endpoint 和服务端状态,不自动重发。 - `mcp_auth_required`:检查所选 server 配置中的 headers,不自动 OAuth 登录或更换身份。 - `mcp_permission_denied`:检查服务、工具与业务权限,不一律归因于票据过期。 - `mcp_tool_not_found` / `invalid_mcp_arguments`:本地校验失败,没有调用工具;重新查看工具帮助。 diff --git a/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml b/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml index 6af717d..a327e25 100644 --- a/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml +++ b/tests/integration/cases/mcp/MCP-003-arguments-errors.yaml @@ -38,3 +38,18 @@ steps: - {path: data.rpc_error.code, equals: -32602} - {path: data.rpc_error.message, equals: Unknown tool} - {path: headers.Mcp-Session-Id, equals: '***'} + - name: retain the terminal SSE event at EOF + command: [mcp, fixture, echo, --text, sse-eof] + expect: + exit_code: 0 + checks: + - {path: data.result.structuredContent.arguments.text, equals: sse-eof} + - {path: data.result.structuredContent.big, equals: 9007199254740993} + - name: preserve RPC errors carried by HTTP 400 + command: [mcp, fixture, echo, --text, rpc-http-error] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: mcp_rpc_error} + - {path: data.rpc_error.code, equals: -32602} + - {path: data.rpc_error.message, equals: Unknown tool} diff --git a/tests/integration/cases/mcp/MCP-005-offline.yaml b/tests/integration/cases/mcp/MCP-005-offline.yaml index 967fa8d..d157697 100644 --- a/tests/integration/cases/mcp/MCP-005-offline.yaml +++ b/tests/integration/cases/mcp/MCP-005-offline.yaml @@ -29,3 +29,9 @@ steps: exit_code: 1 checks: - {path: error.code, equals: invalid_mcp_arguments} + - name: short help cannot become a tool argument + command: [mcp, offline, unverified, --text, -h] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_mcp_arguments} diff --git a/tests/integration/mock_api/app.py b/tests/integration/mock_api/app.py index 6d2eda3..8890a82 100644 --- a/tests/integration/mock_api/app.py +++ b/tests/integration/mock_api/app.py @@ -103,9 +103,11 @@ def mcp_endpoint(): elif method == "tools/call": params = rpc.get("params", {}) name = params.get("name") - if name == "echo" and params.get("arguments", {}).get("text") == "rpc-error": + if name == "echo" and params.get("arguments", {}).get("text") in ("rpc-error", "rpc-http-error"): response = jsonify({"jsonrpc": "2.0", "id": rpc["id"], "error": {"code": -32602, "message": "Unknown tool"}}) response.headers["Mcp-Session-Id"] = "integration-private-session" + if params.get("arguments", {}).get("text") == "rpc-http-error": + response.status_code = 400 return response if name == "slow": time.sleep(0.3) @@ -116,6 +118,8 @@ def mcp_endpoint(): else: return jsonify({"jsonrpc": "2.0", "id": rpc.get("id"), "error": {"code": -32601, "message": "unknown method"}}) message = json.dumps({"jsonrpc": "2.0", "id": rpc["id"], "result": result}) + if method == "tools/call" and rpc.get("params", {}).get("arguments", {}).get("text") == "sse-eof": + return Response("data: " + message + "\n", content_type="text/event-stream") if request.headers.get("X-Mock-Scenario") == "MCP-004": return Response("event: message\ndata: " + message + "\n\n", content_type="text/event-stream") return Response(message, content_type="application/json")