1. 从零搭一个自定义 MCP Server,为什么我建议先跑通 http 与 stdio 两种模式
mcp sdk 自定义 mcp server 这件事,真正卡人的地方从来不是写工具函数,而是「传输模式选错、鉴权没打通、客户端连不上」这三件事。MCP(Model Context Protocol)本质上是给大模型装的一根「外接数据线」:模型本身不会查你的数据库,但通过 MCP Server 暴露出来的 tool,它就能按标准 JSON-RPC 协议去调用你已有的业务接口。适合谁?适合手里已经有一套 Spring Boot 业务服务(用户、角色、学校这类 CRUD 接口),想让 Cursor、Claude Code 这类客户端直接操作业务数据的后端同学。
这篇聚焦 mcp sdk 从零搭建自定义 mcp server,把 http 模式和 stdio 模式各跑一遍,并且统一走 TaoToken 的 Key/API 通道完成鉴权。为什么要统一 Key?因为一旦你同时接多个客户端、多个模型,Key 散落在各处非常难管,统一通道之后,Base URL、Key、Model ID 三件套集中配置,换模型只改一处。
两种模式的核心差异先讲清楚,后面配置才不会晕:
| 维度 | http 模式 | stdio 模式 |
|---|---|---|
| 传输方式 | Streamable-HTTP,监听端口 | 标准输入输出,子进程 |
| 启动形态 | 常驻 Web 服务 | 客户端拉起 jar 进程 |
| 鉴权位置 | URL 参数 / Header | arguments / 环境变量 |
| 适合场景 | 多人共享、远程调用 | 本地单机、IDE 集成 |
| 调试方式 | curl 直接打 | 手动喂 JSON-RPC |
http 模式像开了一家店,谁都能按地址来;stdio 模式像你随身带的工具包,客户端启动时才打开。理解这一点,后面的配置就顺了。
2. TaoToken 统一 Key 通道前置准备:Base URL、Key 与 Model ID 三件套
在写 MCP Server 之前,先把模型侧的通道准备好。TaoToken 在这里扮演的角色是「统一入口」:你的 MCP Server 负责暴露业务工具,而模型推理走 TaoToken 的 API 通道,两边通过统一的 Key 管理,避免每个客户端各配一套。
第一步,去控制台创建 API Key。打开 https://taotoken.net/api-keys ,登录后新建一个 Key,复制出来先存好,后面配置里会用到。注意这个 Key 只显示一次,丢了只能重建。
第二步,确认 Base URL。TaoToken 的 API 入口是 https://taotoken.net/api ,所有兼容 OpenAI 协议的客户端都填这个地址。注意这里不要带任何多余路径,客户端一般会自动拼/v1/chat/completions。
第三步,选 Model ID。在模型对话页面可以先试一下哪个模型符合你的需求:https://taotoken.net/models 。选好之后把 Model ID 记下来,比如常见的对话模型 ID,配置时原样填入。
三件套整理成一张表,方便你对照:
| 配置项 | 值 | 用途 |
|---|---|---|
| Base URL | https://taotoken.net/api | 所有请求的根地址 |
| API Key | 控制台生成 | 鉴权凭证 |
| Model ID | 模型对话页选择 | 指定推理模型 |
如果你打算长期做编码类 Agent,建议直接看 Coding Plan:https://taotoken.net/coding-plan ,它把编码场景的额度和模型打包好了,比单次调用省心。接入文档在 https://taotoken.net/doc ,遇到协议细节可以对照。
这里有个容易踩的坑:很多人把 Base URL 写成带/v1的完整路径,结果客户端又拼一次,变成/v1/v1/...直接 404。记住根地址就是https://taotoken.net/api,剩下的交给客户端。
准备好这三件套,MCP Server 侧的 appKey 鉴权就可以和模型通道解耦:MCP 用 appKey 保护你的业务接口,模型用 TaoToken Key 走推理,各管各的,互不干扰。
3. 可复制配置:http 模式与 stdio 模式的 server 配置片段
这一节给可直接复制的配置。先看 http 模式的 pom 依赖,核心是mcp-bom和mcp-spring-webmvc:
<dependencyManagement> <dependencies> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-bom</artifactId> <version>0.12.1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp</artifactId> </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-spring-webmvc</artifactId> </dependency> </dependencies>http 模式的application.properties,注意端口和 appKey:
server.port=7778 backend.base-url=http://localhost:6666/securityAIDemo backend.user-account=admin backend.password=123 mcp.app-key=demo-key-001http 模式的关键配置类是McpServerConfig,它把 MCP 挂在/mcp路径上,并用一个 Filter 做鉴权:
@Configuration public class McpServerConfig implements WebMvcConfigurer { public static final String MCP_ENDPOINT = "/mcp"; @Bean(name = "mcpObjectMapper") public ObjectMapper mcpObjectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); return mapper; } @Bean public HttpServletStatelessServerTransport httpServletStatelessServerTransport( @Qualifier("mcpObjectMapper") ObjectMapper mcpObjectMapper) { return HttpServletStatelessServerTransport.builder() .objectMapper(mcpObjectMapper) .messageEndpoint(MCP_ENDPOINT) .build(); } }注意FAIL_ON_UNKNOWN_PROPERTIES一定要关掉。Cursor 这类客户端会发 2025-11-25 规范里的capabilities.elicitation.form字段,0.12.x 的 SDK 反序列化时会报Unrecognized field "form",关掉这个开关就绕过去了。
再看 stdio 模式。pom 里去掉mcp-spring-webmvc,只留mcp:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp</artifactId> </dependency> </dependencies>stdio 模式的application.properties要关掉 Web 容器,否则 Spring 会往 stdout 打日志,污染 JSON-RPC:
spring.main.web-application-type=none spring.main.banner-mode=off backend.base-url=http://localhost:6666/securityAIDemo backend.user-account=admin backend.password=123 mcp.app-key=demo-key-001stdio 的传输配置用StdioServerTransportProvider:
@Configuration public class StdioMcpConfig { @Bean public StdioServerTransportProvider stdioServerTransportProvider( @Qualifier("mcpObjectMapper") ObjectMapper mcpObjectMapper) { return new StdioServerTransportProvider(mcpObjectMapper); } @Bean public McpSyncServer stdioMcpSyncServer( StdioServerTransportProvider stdioProvider, AppKeyService appKeyService, BackendApiClient backend) { McpSyncServer server = McpServer.sync(stdioProvider) .serverInfo("security-ai-mcp", "1.0.0") .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) .build(); addUserTools(server, appKeyService, backend); return server; } }stdio 模式必须配logback-spring.xml,把日志全部打到 stderr:
<configuration> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <target>System.err</target> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> </root> </configuration>客户端侧的配置也要给全。Cursor 的mcp.json里,http 模式这样写:
{ "mcpServers": { "security-ai-mcp": { "url": "http://localhost:7778/mcp?appKey=demo-key-001" } } }stdio 模式这样写,注意env里传 appKey:
{ "mcpServers": { "security-ai-mcp": { "command": "java", "args": ["-jar", "c:/mydemo/security-ai-mcp-demo/target/security-ai-mcp-demo-1.0-SNAPSHOT.jar"], "env": { "MCP_APP_KEY": "demo-key-001" } } } }如果你用的是 Claude Code,配置在~/.claude/settings.json或项目级 settings 里,Base URL 填https://taotoken.net/api,Key 填控制台生成的,Model ID 填你选的模型。三件套齐全,Claude Code 才能正常走 TaoToken 通道。
4. 验证请求:一次 curl 与一次 stdio JSON-RPC 的成功结果
配置写完,先验证 http 模式。启动服务:
cd security-ai-mcp-demo mvn spring-boot:run看到MCP: transport at /mcp日志就说明起来了。然后用 curl 打一次tools/call:
curl -X POST "http://localhost:7778/mcp?appKey=demo-key-001" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"user_list\",\"arguments\":{\"account\":\"zhangsan\",\"pageNum\":1,\"pageSize\":10}}}"注意Accept头必须同时包含application/json和text/event-stream,否则 Streamable-HTTP 会拒绝。成功的话你会拿到类似这样的返回:
{ "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"data\":{\"records\":[{\"id\":1,\"account\":\"zhangsan\"}],\"total\":1},\"code\":200}" } ], "isError": false } }isError为 false,说明工具调用成功,业务数据也透传回来了。
再验证 stdio 模式。先打包再启动:
cd security-ai-mcp-demo mvn clean package -DskipTests java -jar target/security-ai-mcp-demo-1.0-SNAPSHOT.jar启动后你会看到Server is ready. Waiting for JSON-RPC requests on stdin...。stdio 模式必须按顺序喂三条消息,顺序错了没反应。
第一条,初始化:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}第二条,发已初始化通知(少了这条,后面的 tools/call 会被挂起):
{"jsonrpc":"2.0","method":"notifications/initialized"}第三条,调用工具:
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"school_list","arguments":{"appKey":"demo-key-001","pageNum":1,"pageSize":5}}}成功返回:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"data\":{\"records\":[{\"id\":1,\"name\":\"第一中学\"}],\"total\":11},\"code\":200}"}],"isError":false}}如果你故意去掉appKey再调一次,会拿到:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Invalid or missing appKey"}],"isError":true}}这说明鉴权生效了。http 和 stdio 两种模式到这里都跑通了,业务接口的返回值也正确透传。
5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth 报错
跑 demo 时最容易撞上的几类报错,我按现象、原因、解法整理出来。
第一类,401 Unauthorized或Invalid or missing appKey。http 模式下,检查 URL 里的?appKey=demo-key-001是否和application.properties里的mcp.app-key完全一致,大小写、空格都算。stdio 模式下,检查mcp.json的env.MCP_APP_KEY是否传进去了,或者arguments里有没有带appKey。如果两个都没传,getAppKeyFromArgs返回 null,直接判失败。
第二类,local proxy failed或连接被拒。这种多半是端口没起来或者被占用。http 模式确认server.port=7778没被别的进程占,用netstat -ano | findstr 7778查一下。stdio 模式确认 jar 路径写对了,Windows 下路径用正斜杠或双反斜杠,c:/mydemo/...这种写法最稳。
第三类,reading choices或Unrecognized field "form"。这是 SDK 版本和客户端规范不匹配。解法就是前面说的,ObjectMapper关掉FAIL_ON_UNKNOWN_PROPERTIES,并且在 Filter 里把capabilities.elicitation.form和url字段 strip 掉。McpElicitationStripRequestWrapper就是干这个的,它读 body、删字段、再包回去。
第四类,OAuth 相关报错。如果你在客户端里配了 OAuth 流程但没配好,会一直卡在授权。MCP Server 这边其实用的是 appKey 简单鉴权,不需要 OAuth。检查客户端配置里有没有多余的auth字段,删掉,只留url或command。
第五类,stdio 模式发了tools/call没反应。九成是漏了notifications/initialized这条通知。MCP 服务会等这条再处理后续请求,顺序必须是 initialize → initialized → tools/call。
第六类,日志污染导致 JSON-RPC 解析失败。stdio 模式下如果看到 stdout 里混进了 Spring 的 banner 或 INFO 日志,客户端会解析失败。确认spring.main.banner-mode=off和logback-spring.xml的System.err都配了。
第七类,Backend returned 403。这是你的 MCP Server 调后端业务接口时 token 过期或权限不足。BackendApiClient里已经做了 401/403 清 token 重试一次的逻辑,如果还报,检查backend.user-account和backend.password是否正确。
排查时建议开 debug 日志,把logging.level.io.modelcontextprotocol=DEBUG加上,能看到完整的 JSON-RPC 收发过程,定位快很多。
6. 把 MCP Server 接到 TaoToken 通道:Key 管理与后续扩展
两种模式跑通之后,最后一步是把模型侧也统一到 TaoToken 通道。MCP Server 负责暴露工具,模型负责决策调用哪个工具,两边通过客户端串起来。客户端里配置 TaoToken 的三件套:
{ "baseUrl": "https://taotoken.net/api", "apiKey": "你的 TaoToken Key", "model": "你选的 Model ID" }这样 Cursor 或 Claude Code 在推理时走 TaoToken,调用工具时走你的 MCP Server,职责清晰。Key 管理上,建议 MCP 的 appKey 和 TaoToken 的 API Key 分开存,前者保护业务接口,后者管模型额度,泄露一个不影响另一个。
后续扩展方向有几个。一是把 appKey 从写死改成动态下发,和权限系统关联,不同用户拿不同 appKey,工具调用时按 scope 过滤。二是工具数量多了之后,用tools/list做分组,客户端按需加载,避免一次暴露几十个工具让模型选花眼。三是 http 模式可以加限流,stdio 模式可以加超时,防止单个客户端把服务打满。
如果你要长期跑编码类 Agent,Coding Plan 比按次调用更划算,额度打包、模型固定,省去每次选模型的纠结。接入文档里有完整的协议说明和示例,遇到字段对不上可以对照查。
实测下来,http 模式适合团队共享一个 MCP 服务,stdio 模式适合个人本地开发,两者配置差异主要在传输层和鉴权位置,工具注册逻辑几乎可以复用。把这篇的配置片段复制过去,改一下端口和 appKey,十分钟就能跑通自己的第一个自定义 MCP Server。