
























模型上下文协议(Model Context Protocol)让 LLM 客户端接入既有系统变得更加容易,但多数示例仍停留在“Demo 看起来有意思”为止。更难的问题在于,当相同的集成触及真实的业务数据、真实流程和真实运维约束时,会发生什么?
在我们的场景中,我们希望通过 MCP 服务器把一个基于超过 100 万家企业档案构建的 B2B 情报平台暴露给 LLM 客户端。面向用户的想法很简单:用户无需打开门户、输入查询、人工筛选、手动导出,只需提出结构化的请求,例如,“查找德国 50-200 人规模的 SaaS 公司”,就能在 LLM 客户端获得结果。但是,工程方面的问题远远不是这么简单的,如何让这一流程可用,同时避免把 LLM 与生产数据的连接成不安全的通道呢?
这个问题从一开始就决定了其实现方式。我们没有把 MCP 服务器当作现有 API 的便捷包装层,而是将其视为一等接口,并为其单独定义契约、安全假设、测试策略和运维控制。
由于底层平台服务提供的是 100 万家企业的档案,所以,MCP 层从一开始就必须按照可扩展系统来进行设计,而不是轻量级的实验性集成。在这个规模下,清晰的工具边界、可预测的请求处理和可审计性不仅关乎安全,也直接关系到工程师与用户能否理解系统的行为。
平台原本已经通过 AWS AppSync 上的GraphQL对外提供数据,这为读取业务对象提供了清晰的后端边界。我们另外构建了一个基于 Go 的 MCP 服务器,把用户请求转换为一组边界明确的工具,而不是把业务逻辑推给 LLM 层。实现上使用了 mcp-go、面向 AppSync 的 GraphQL 客户端,以及覆盖搜索、AI 辅助搜索和集合操作的工具层。
该架构带来了两点收益。首先,AppSync 可以继续作为后端访问的系统事实来源,避免 MCP 层演化为临时拼接的集成面。其次,工具层可以围绕明确的职责进行设计,使系统更易测试,也更易推理。
实践证明,这套架构比协议本身更重要。MCP 只提供了连接模型,真正的生产问题在于每个工具被授予了多大的能力,以及其行为的定义是否足够精确。如果工具契约模糊,MCP 服务器将难以验证、难以观测,也容易被误用。
从实现层面看,MCP 服务器承担的是“契约执行层”而非“透传代理”的角色。用户请求先被规范化为显式的工具调用,再映射到边界受控的 GraphQL 操作,响应结构也保持足够收敛,从而可独立于 LLM 客户端进行校验。这种分层降低了提示词歧义直接泄露到后端行为的风险。
当用户通过 LLM 客户端发起请求时,系统按以下层次依次进行处理:
LLM 客户端通过 MCP 的 stdio 传输发送工具调用,并携带 JSON 参数(例如,{"query":"SaaS companies in Germany","country":"DE","limit":20})。MCP 服务器通过mcp-go库接收调用,并将其分发给已注册的工具处理器。
参数解析与校验。每个工具先把原始的map[string]any参数通过 JSON 序列化/反序列化解析为类型化的 Go 结构体,再执行校验:必填字段检查、limit 封顶检查(比如,最大值为 100)、输入裁剪与规范化。对写工具会先检查mutationsAllowed布尔值。如果校验失败,立即返回错误,不触达后端。
执行 GraphQL。工具构建变量映射并调用client.Execute(ctx, query, variables, &result)以访问 AppSync。GraphQL 客户端统一处理认证(OIDC Bearer Token、API Key 或 AWS SigV4 签名)和 HTTP 层错误(401/404/429)。
响应整理。GraphQL 响应会先反序列化为内部类型(比如,gqlCompanyModelV2、gqlCollection),再映射为扁平、对 AI 友好的公开类型(如CompanySummary、CompanyDetail、Collection)。搜索/读取类工具通过toCompanySummary()等函数实现扁平化;写入工具会返回最小化的结果结构(比如,add_to_collection仅返回{collection_id,success})。
序列化与返回。结果被序列化为 JSON,并作为CallToolResult文本内容块返回给 LLM 客户端。
关键点在于校验、执行、响应整理分别由独立的层来进行处理。MCP 服务器既不会把原始用户输入直接传给 GraphQL,也不会把原始 GraphQL 响应直接回传给客户端。
实现中定义了 9 个工具,按能力分组如下:
在上线时的配置中,实际暴露了上述 9 个工具里的 8 个。create_collection在集成测试暴露了后端 Lambda 的错误后,已经从注册路径中移除。
最重要的实现决策之一,就是从一开始就要分离读操作与写操作。许多早期 MCP 示例会把工具的功能做得很宽泛:既能搜索也能更新,还能编排动作。在原型阶段这也许是可以接受的,但一旦接口连接真实的系统,就会产生不必要的歧义。
当模型可触达业务数据时,“查询”和“修改”不能只靠约定区分,而应在工具设计层面硬性分离。在我们的实现中,只读路径会保持只读,所有可变更动作默认都会阻断。
这一策略不仅更安全,也更易于维护。只读工具更易审查、更易测试、更易观测,因为它表达的是单一意图而非多重意图。实践中,严格的契约比在过度灵活的工具上的事后补救策略更可靠。
这一点在百万级数据集上尤为关键。即便是微小的工具行为歧义,也可能放大为结果混乱、查询范围过宽或运维误判。严格保持只读路径为只读,显著降低了早期落地阶段需要“盲信”的行为范围。
读写分离要在注册层面强制执行。创建工具注册中心时,会把allowMutations标记传入每个可变更工具中;只读工具不接收这个标记,因为它们不存在写路径:
func NewRegistry(gqlClient graphql.Client, allowMutations bool) *Registry { return &Registry{ gqlClient: gqlClient, // Read-only tools searchCompanies: NewSearchCompaniesTool(gqlClient), getCompany: NewGetCompanyTool(gqlClient), getCompaniesBatch: NewGetCompaniesBatchTool(gqlClient), aiSearch: NewAISearchTool(gqlClient), listCollections: NewListCollectionsTool(gqlClient), getCollectionItems: NewGetCollectionItemsTool(gqlClient), // Mutation tools createCollection: NewCreateCollectionTool(gqlClient, allowMutations), addToCollection: NewAddToCollectionTool(gqlClient, allowMutations), requestEmailDiscovery: NewRequestEmailDiscoveryTool(gqlClient, allowMutations), }}复制代码
每个写工具都在内部保存该标记,并在Execute入口最先进行检查:
func (t *CreateCollectionTool) Execute(ctx context.Context, params CreateCollectionParams) (*CreateCollectionResult, error) { if !t.mutationsAllowed { return nil, fmt.Errorf("mutations are disabled; use --allow-mutations flag to enable write operations") } // ... validation and execution follow}复制代码
这样一来,不管 LLM 客户端主观意图是什么,只要未打开该标记,写工具都会立刻、可预测地失败。
search_companies输入契约:
type SearchCompaniesParams struct { Query string `json:"query"` // required Country string `json:"country,omitempty"` // ISO 3166-1 alpha-2 or full name Limit int `json:"limit,omitempty"` // default 10, max 100}复制代码
输出结构:
{ "companies": [ { "id": "example.com", "name": "Example Inc", "description": "Cloud infrastructure provider...", "country": "United States", "countryCode": "US", "locality": "San Francisco", "employeeCount": 150, "employeeRange": "101-250", "domain": "example.com", "industryTags": ["Technology", "Cloud Computing"] } ], "total": 42}复制代码
该工具会校验 query 非空、把 limit 属性封顶到 100、解析国家代码(例如,把DE映射为countries;Germany),并把嵌套的 GraphQL 响应扁平化为CompanySummary记录。
add_to_collection输入契约:
type AddToCollectionParams struct { CollectionID string `json:"collection_id"` // required CompanyIDs []string `json:"company_ids"` // required, non-empty}复制代码
输出结构:
{ "collection_id": "col-abc-123", "success": true}复制代码
该工具先检查mutationsAllowed,再校验参数并执行基于 itemType 执行 GraphQL 变更操作(itemType 为COMPANY)。该操作具备幂等性,也就是向集合重复添加已存在的公司也会成功,不会报错。
我们在所有写能力前引入了显式的--allow-mutations标记。这不是完整的授权模型,但它是一个有效的控制点:迫使团队在每个环境中明确决定是否开放写路径。
这之所以重要,是因为 LLM 集成通常从实验起步,随后逐步承载运维方面的期望。一旦系统变得有价值,通常会出现“让它多做一点”的压力。如果初始设计默认就是宽泛地进行访问,那么这种压力往往会演化为通往不安全的捷径。
默认拒绝会改变讨论方式。团队不会再问“为什么不让客户端执行写入操作?”,而是必须问“在允许这类写操作之前,我们还需要哪些证据?”这个问题更工程化,因为它把权限扩张与证据、控制和运维成熟度绑定了起来。
该标记也构建了实验环境与生产环境之间的实用边界:当安全优先级高于便利性时,写能力必须成为有意识的选择,而不是随着功能增强悄然出现的默认行为。
该标记在Cobra CLI中注册,默认值为 false:
serveCmd.Flags().BoolVar(&allowMutations, "allow-mutations", false, "Enable write operations (create collections, add items, etc.)")复制代码
服务启动时,该值会传入工具注册中心:
toolsRegistry := tools.NewRegistry(gqlClient, allowMutations)复制代码
启动日志会记录该标记的状态和其他配置:
level=INFO auth=oidc mutations=false tools=8 resources=2 prompts=2复制代码
当前实现没有在该标记后再添加额外的授权判断,它是一个二元值的门控:要么所有写工具可执行,要么全部不可执行。这是初始版本有意采取的选择,因为简单、可见的控制通常优于容易误配的复杂机制。后续可以在使用模式稳定后叠加按工具或按用户的细粒度授权。
在 MCP 客户端配置的.mcp.json中,这个标记通过 CLI 参数传入,便于在审查集成配置时直接可见:
{ "mcpServers": { "mcp-server": { "command": "/path/to/mcp-server", "args": ["serve", "--endpoint", "https://api.example.com/graphql", "--region", "eu-west-1", "--allow-mutations"] } }}复制代码
一个关键的经验是,工具数量并非核心,工具形态才是。如果每个工具都有明确的输入模型、受限的输出结构和较小的运维面,那么 9 个功能受限的工具往往比两个通用工具更安全。尤其在面对概率式客户端且同一意图可能被不一致表达时,这一点更为明显。
严格的契约能对冲这种不确定性,让故障更易诊断、结果更易校验、变更更易管理。相反,宽松的工具往往会逐渐演化为后端的遥控器层,并在长期内同时恶化安全性与可维护性。
它还会影响版本演进。严格定义的工具通常可以在稳定契约后平滑演进;宽松的工具则会积累大量的隐式依赖(提示词行为与用户预期),小幅度的改动也可能产生难以预测的回归测试问题。
在这个规模下,输出边界与输入边界同样重要。持续返回一致的结构,有助于评估结果质量、降低意外,并使测试、日志与客户端解释保持可预测性。
在这 9 个工具中,search_companies是很好的参考契约,因为它在单一路径里覆盖了三件事:输入校验、输入规范化(国家代码解析与 limit 封顶设置)以及把嵌套的 GraphQL 响应扁平化为单层记录。
我们在实现中使用 aws-vault 进行认证,并借助MCP Inspector在脱离 LLM 客户端的情况下对工具进行验证。初始看上去这像是开发便利性方面的选择,但实质上是可靠性方面的选择。
如果工程师只能通过最终的客户端验证 MCP 服务器,调试会更慢、接口更不透明。本地检查路径可以直接查看请求/响应的结构、确认工具的行为,并在引入 LLM 前隔离失败点,从而降低端到端测试中的歧义。
良好的本地闭环还能降低“做对的事”的成本。团队更容易保持测试及时更新、契约收敛。相反,本地闭环如果非常痛苦的话,其结果通常不是纪律性变强,而是系统漂移。
面向生产的流程中,本地验证还能确认“失败是否可理解”,避免叠加 LLM 不确定性后故障表达失真。在后端已经有清晰的契约后,MCP 层的职责是维护这些契约而不是削弱它们。
MCP 服务器通过平台的身份提供者(identity provider)所签发的 OIDC Bearer Token 向 AppSync 进行认证。每个 Token 都会绑定到已认证用户、短有效期且仅覆盖该用户被授权的操作。AppSync 通过@aws_oidc在 resolver 层执行认证,因此过期或无效 Token 会在 resolver 逻辑之前就被拒绝。
这一点对 MCP 服务器尤为重要,因为 LLM 客户端是代表具体用户而不是通用服务执行操作。如果服务器使用静态 API Key 或共享服务凭证的话,无论请求由谁发起,它都会携带同样的权限。使用 OIDC 后,Token 内会含有用户标识,后端授权、轨迹审计和数据范围都可以与用户直接调用 API 保持一致。MCP 层不会绕过或削弱既有的访问模型,而是继承它。
从运维角度来看,短期 Token 也能降低凭据泄露的爆炸半径。泄露的 API Key 在轮换前会一直有效,而泄露的 OIDC Token 通常在数小时内会自动过期。对于可触达百万企业档案的 MCP 服务器来说,这一差异非常关键。
服务会在启动时记录当前的认证方式,便于运维在避免探查流量的情况下核对配置:
level=INFO auth=oidc mutations=false tools=8
MCP Inspector 通过 stdio 连接服务器,让工程师在不经过 LLM 客户端的情况下直接发起工具调用。典型验证会话如下:
> Tool: search_companies> Args: {"query": "fintech", "country": "GB", "limit": 5}< Result: {"companies": [{"id": "revolut.com", "name": "Revolut", ...}], "total": 127}复制代码
> Tool: add_to_collection> Args: {"collection_id": "col-123", "company_ids": ["revolut.com"]}< Error: "mutations are disabled; use --allow-mutations flag to enable write operations"复制代码
第二种场景在开发期的价值尤其明显,它验证了变更门控返回的是明确、有效的错误信息,而非模糊的失败,这对 LLM 客户端向用户解释失败原因时非常重要。
常见误区是,如果后端 API 可用的话,那么 MCP 层主要就是传输的问题。实际情况并非如此,MCP 层会引入自己的故障模式,比如,脆弱的校验、工具语义不明确、错误处理不佳、工具行为与后端假设不匹配。因此我们把它视为独立的接口,并设计了独立的测试策略。
实现采用 TDD 的方式,单元测试层面使用 Mock GraphQL 客户端测试工具的逻辑,同时通过 MCP Inspector 对真实 AppSync 端点做手工验证。Mock 有助于隔离逻辑和覆盖边界;真实后端验证则确认真实线路能够在实际条件下符合预期。
负向测试同样关键。只验证“合法请求能成功”远远不够,还必须验证“非法输入会明确失败、应该阻断的变更要确实被阻断、工具错误不会诱导混乱或不安全的重试”。这类测试的重点不是抽象意义上的正确性,而是契约纪律。相比“宽容但不可控”的工具,“可预测失败”的工具通常更安全。
分层测试很重要,因为 MCP 服务器不只是做请求的翻译,还在执行安全边界。单元测试会验证工具的局部行为,MCP Inspector 则会验证真实流里 AppSync 边界、认证路径和响应契约是否仍能协同成立。
Mock 客户端基于Testify Mock实现,并复用与真实客户端一致的 graphql.Client 接口:
type MockClient struct { mock.Mock}func (m *MockClient) Execute(ctx context.Context, query string, variables map[string]any, result any) error { args := m.Called(ctx, query, variables, result) return args.Error(0)}复制代码
在测试中,Mock 可直接向 result 指针注入响应,完全绕过 HTTP:
func TestSearchCompanies_ValidQueryReturnsResults(t *testing.T) { mockClient := graphql.NewMockClient() tool := NewSearchCompaniesTool(mockClient) mockClient.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Run(func(args mock.Arguments) { result := args.Get(3).(*gqlSearchCompaniesResponse) result.EnhancedSearchCompanies.Companies = []gqlCompanyModelV2{ {ID: "example.com", CompanyModel: gqlCompanyModel{ Company: gqlCompany{Name: "Example Inc", Domain: "example.com"}, IndustryTags: []string{"Technology"}, }}, } result.EnhancedSearchCompanies.TotalResults = 1 }). Return(nil) result, err := tool.Execute(context.Background(), SearchCompaniesParams{ Query: "tech companies", Limit: 10, }) require.NoError(t, err) assert.Len(t, result.Companies, 1) assert.Equal(t, "example.com", result.Companies[0].ID)}复制代码
这种方式还可捕获发送给 GraphQL 的变量,用于验证规范化逻辑是否在请求进入后端前已生效:
var capturedVariables map[string]anymockClient.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Run(func(args mock.Arguments) { capturedVariables = args.Get(2).(map[string]any) // ... set result }).Return(nil)// After execution:limit := capturedVariables["limit"].(int)assert.Equal(t, 100, limit, "limit should be capped to 100")复制代码
该技术首先暴露了国家代码的映射错误,早期版本把US直接传给 GraphQL,而 API 实际要求的是countries;United States这样的格式。由于后端对错误的过滤会返回空集,如果仅断言输出结构,Mock 测试仍会“看起来能够通过”。
变更门控逻辑采用测试先行的方式。在实现前,先要编写测试定义的预期行为:
func TestCreateCollection_BlockedWithoutMutationsFlag(t *testing.T) { mockClient := graphql.NewMockClient() tool := NewCreateCollectionTool(mockClient, false) // mutations NOT allowed result, err := tool.Execute(context.Background(), CreateCollectionParams{ Name: "Test Collection", }) require.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "mutations") assert.Contains(t, err.Error(), "--allow-mutations") mockClient.AssertNotCalled(t, "Execute") // must not reach GraphQL}复制代码
最初,该测试会失败,因为 Execute 中尚无变更检查。
func (t *CreateCollectionTool) Execute(ctx context.Context, params CreateCollectionParams) (*CreateCollectionResult, error) { if !t.mutationsAllowed { return nil, fmt.Errorf("mutations are disabled; use --allow-mutations flag to enable write operations") } // ... rest of implementation}复制代码
加入守卫后,测试通过:错误信息同时包含mutations与--allow-mutations,result 为 nil,更关键的是mockClient.AssertNotCalled(t,"Execute")证明了在触达后端前就已阻断。
读写分离很快产生了收益。当我们把 MCP 服务器接入 LLM 客户端时,只读工具具备了较高的可预测性,不再需要在早期测试中反复担心“模型是否改动了什么内容”。这一类问题在架构层面直接消除了。
Mock 测试中的变量捕获能提前发现规范化的问题。通过断言实际发送给 GraphQL 的变量(而不是只看最终输出),我们识别了国家代码未被正确解析、limit 未在进入后端前设置封顶值等问题。如果仅校验输出结构,这些问题就会被掩盖。
扁平化响应类型让 LLM 的行为更具有可预测性。把嵌套 GraphQL 类型(gqlCompanyModelV2 -> companyModel -> company -> description.text)转换为扁平化的结构(例如,含单一描述字符串的CompanySummary)后,模型解释与呈现结果的波动显著降低。
从活跃工具集中移除create_collection是必要的。它在 Mock 单元测试中表现正常,但在通过 MCP Inspector 接入真实 AppSync 时暴露了后端 resolver 中的 Lambda 空指针错误,最终该工具从注册路径中注释掉了。这是一个典型的案例,仅对 MCP 层本身进行测试还不够,真实的后端验证是不可替代的。在我们的 dev-team-a 测试环境中,该错误可稳定复现,因此我们选择直接移除对该工具的注册,而不是将其隐藏在变更标记之后,让这个问题悬而未决。
AI 搜索的限流需要前置面向会话的模式设计。默认 5 次/分钟的阈值会较为保守,但工具从一开始就采用了可配置的AISearchConfig,因为我们预期多轮对话(基于当前会话连续追问)会快速达到这个峰值。提前实现环境级的可配置,避免了首次真实使用就必须修改代码来调整限流。之所以选 5 次/分钟,是因为典型的连续澄清通常在短时间内触发 2-4 次追问,略高于该范围既能支持真实的多轮场景,也能抑制失控的 LLM 循环。
面对超过 100 万条的档案时,宽泛的查询从一开始就必须受到限制。以“companies”这类无国家过滤的查询为例,它的结果可能覆盖整个库。即使工具层硬限制返回 100 条(默认 10 条),依然可能出现“范围过宽且几乎无用”的场景,并诱发 LLM 客户端持续追问导致请求范围进一步扩散。基于此,我们在部署前做了两项决策,首先,在search_companies中内置类别过滤体系,让国家和其他约束在到达后端前先收敛范围;其次,为ai_search设置独立的通道与内置限流,使范围更难预测的自然语言查询可以单独节流。例如,早期一个裸请求{query:"companies"}会命中百万级数据并返回近似随机的 10 条结果;而受约束版本会在工具契约层引导 LLM 至少提供位置类过滤后再访问 AppSync。
我们从设计之初就纳入了请求日志,因为 MCP 服务器被视为连接业务数据的真实网关而非实验性的脚手架。该接口存在之后,团队需要看到:哪些工具在被调用、它们在什么条件下被调用、结果如何。如果缺乏这些可见性,就很难判断工具边界是否合理,或者使用模式是否正漂移到不安全的方向。
传统后端团队普遍能够接受日志、追踪和监控的必要性,而 LLM 系统经常因为探索优先的理念而推迟这件事。但是,它的代价通常很高。越早把可观测性放入 MCP 层,越容易理解真实使用的情况,从而收紧契约,并评估何时应扩大能力边界,这也更利于后端、平台与产品团队基于客观证据的协作。
在服务百万企业档案的平台上,日志不仅能够用于事后排查故障,更是大规模场景下纪律的一部分:它有助于区分有价值的严格请求,以及过于宽泛、频繁、成本过高且不宜通过 LLM 接口安全承载的调用模式。
在当前的实现中,我们使用 Go 结构化日志包slog输出到 stderr(stdout 预留给 MCP JSON-RPC 协议)。服务启动时会以结构化字段的方式记录配置:
level=INFO auth=oidc mutations=false tools=8 resources=2 prompts=2
这条启动日志已经足以验证认证方式、变更标记状态和注册工具的数量。例如,看到tools=8而不是tools=9,那么就可以立即确认create_collection未注册。
除启动信息外,当前实现尚未记录每个请求工具的调用和请求级遥测信息。现有提供的是类型化响应错误,它们会通过 MCP 协议返回给 LLM 客户端,包含必要的诊断信息:
GraphQL 层错误:GraphQL 客户端传播UnauthorizedError、NotFoundError、RateLimitError、GraphQLError等类型化错误,并包含 HTTP 状态码和错误消息。
限流错误:触发限流时错误消息会带上配置值(比如,maximum 5 requests per 1m0s),调用方可快速定位原因。
变更门控错误:未启用--allow-mutations调用写工具时,错误会同时给出问题和修复动作(mutations are disabled; use --allow-mutations flag to enable write operations)。
这些错误对调试很有帮助,但它们不等于运维遥测。生产部署仍建议补齐针对每条请求的结构化日志,独立记录工具名、延时、输入格式、结果与错误类型。由于当前工具契约已经比较收敛、错误类型也已明确,这项增强会更容易落地。
在云系统上构建 MCP 服务器的团队,应该先默认 MCP 层是一等接口:优先设计范围较窄的工具、尽早实现读写分离、克制对“先做灵活性”的冲动。如果一个工具看起来太宽泛,那这样的判断基本就是准确的。
要有意识地利用后端边界。在本实现中,AppSync 与 GraphQL 为 MCP 服务器与系统事实层之间提供了清晰地分隔,使整体设计更容易组织和测试。
把本地验证纳入日常流程中:先利用检查工具与 Mock 客户端验证工具的逻辑,然后在接入真实 LLM 客户端前对真实后端做验证。最后,将日志、审计性与变更控制视为基线要求,而不是后续“再加固”的可选项。
MCP 的价值在于,它降低了 LLM 客户端连接既有关键系统的门槛。因此,生产团队应该更为谨慎。核心挑战不只是“让模型能调 API”,而是“让接口足够收敛、可观测、可测试,并且足够安全以承载真实的业务流程”。
在这个案例中,答案不是某个单一安全功能或提示词的技巧,而是一组被持续贯彻的工程选择:清晰的工具边界、默认的阻断变更、显式认证、本地检查、分层测试与运维可见性。对正在从 MCP Demo 走向生产化系统的团队而言,这些基础仍是最值得优先做好的部分。
查看英文原文:Building a Secure MCP Server on AWS for a Million-Company B2B Platform
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。