什么是 mcp/ChatGPT app
我们需要理解 mcp 和 mcp/ChatGPT app 是什么?
-
MCP = 协议 / 标准
是一种旨在解决该问题的开放标准。MCP 由 Anthropic 于 2024 年 11 月推出,为 LLM 与外部数据、应用和服务之间的通信提供一种安全且标准化的"语言"。它充当桥梁,使 AI 不再局限于静态知识,而成为一个能够检索当前信息并执行操作的动态智能体,从而提升其准确性、实用性与自动化能力。
-
mcp/ChatGPT App = 基于mcp这个标准接入 Chat 的应用形态 (ChatGPT App 应该只能在ChatGPT ,当然好像目前也是唯一一个提供app市场的)
不过这里有两种
-
更统一的方式,更多支持,比如 vscode 等等, 还有 MCP Inspector 调试工具支持,对本地开发会友好一些
-
chatgpt 最早提出app sdk ,所以独有一套, 不过他们也支持了mcp apps mcp-apps-in-chatgpt
-
(当然现在 skills 是更多人使用龙虾这些agent 的方式,但是它不能提供 app 形式,所以这里无需讨论了)
所以我们可以直接使用 mcp apps 来做
MCP 有状态 vs 无状态
MCP 不是"必须有状态",但它最初更偏向有状态会话模型。
原因不是它做不了无状态,而是因为 MCP 想解决的不是普通的"一次请求,一次响应"问题,而是:
- 持续上下文交换
- 多轮工具调用
- 流式输出
- 服务端通知
- 长任务协作
- 初始化能力协商
所以,MCP 在设计上天然更适合"会话式交互",这也是它看起来更像有状态协议的原因。
但在工程实现上,MCP 完全可以按场景做成:
- 有状态(Stateful)
- 无状态(Stateless)
整体结构对比
1)有状态(Stateful MCP)
text
┌────────────────────┐
│ ChatGPT │
│ (MCP Client) │
└────────┬───────────┘
│ 1. init + OAuth
▼
┌──────────────────────────────┐
│ MCP Server (Stateful) │
│ ┌──────────────────────────┐ │
│ │ Session Store │ │
│ │ - session_id │ │
│ │ - user context │ │
│ │ - tool state │ │
│ │ - progress │ │
│ │ - auth/session cache │ │
│ └──────────────────────────┘ │
│ │
│ Tools / Resources / Logic │
└────────┬─────────────────────┘
│
▼
Backend APIs / DB
核心: Server 会保存会话和上下文。
也就是说,服务端知道:
- 当前是谁在调用
- 前面执行到了哪一步
- 当前工具链处于什么状态
- 是否已经完成初始化协商
- 是否已有可复用的授权信息
2)无状态(Stateless MCP)
text
┌────────────────────┐
│ ChatGPT │
│ (MCP Client) │
└────────┬───────────┘
│ 每次请求都完整、自包含
▼
┌──────────────────────────────┐
│ MCP Server (Stateless) │
│ │
│ No session storage │
│ No remembered context │
│ │
│ Tools / Resources / Logic │
└────────┬─────────────────────┘
│
▼
Backend APIs / DB
核心: Server 不保留状态。
每次请求都必须把本次执行所需的信息一起带上,例如:
- Authorization
- 用户标识
- 当前参数
- 回调信息
- 本轮调用上下文
有状态流程(Stateful MCP)
流程图
text
(1) ChatGPT → MCP
initialize
(2) MCP → ChatGPT
返回 session_id = abc123
--------------------------------
(3) ChatGPT → MCP
tool: getOrders
headers:
Mcp-Session-Id: abc123
(4) MCP:
- 读取 session
- 找到用户上下文
- 找到已完成的 OAuth 状态
- 找到前一步协商结果
(5) MCP → ChatGPT
返回订单列表
可同时返回 streaming progress / notification
--------------------------------
(6) ChatGPT → MCP
tool: refundOrder(order_id=123)
headers:
Mcp-Session-Id: abc123
(7) MCP:
- 复用当前 session
- 不需要重新识别用户
- 不需要重新建立上下文
- 可直接执行退款逻辑
(8) MCP → ChatGPT
返回退款结果
特点
优点
- 自动维护上下文
- 适合多步骤流程
- 适合复杂 Agent 协作
- 支持流式返回
- 支持服务端通知
- 适合长任务
- 可以复用授权、能力协商和工具状态
缺点
- 需要额外的 session 存储
- 部署复杂度更高
- 可能需要 Redis、内存态、sticky session 或共享状态层
- 横向扩展时要考虑会话一致性
无状态流程(Stateless MCP)
流程图
text
(1) ChatGPT → MCP
tool: getOrders
headers:
Authorization: Bearer xxx
body:
user_id = 123
(2) MCP:
- 每次重新解析 token
- 每次重新识别用户
- 每次重新决定本次调用逻辑
(3) MCP → ChatGPT
返回订单列表
--------------------------------
(4) ChatGPT → MCP
tool: refundOrder
headers:
Authorization: Bearer xxx
body:
order_id = 123
user_id = 123
(5) MCP:
- 再次校验 token
- 再次读取用户
- 再次独立执行退款
(6) MCP → ChatGPT
返回退款结果
特点
优点
- 架构简单
- 易于部署
- 天然适合 serverless / edge
- 水平扩展简单
- 不需要 session 存储
- 更接近普通 HTTP API 模式
缺点
- 每次请求都要重复传递上下文
- 每次都要重新解析认证
- 不适合复杂的多轮流程
- 不适合长时间任务协作
- 服务端通知和流式体验通常更弱
- 客户端负担更重
核心差异总结
| 维度 | 有状态 MCP | 无状态 MCP |
|---|---|---|
| 会话 | 有 session_id |
无 |
| 上下文 | Server 保存 | Client 每次传 |
| OAuth / Token | 可缓存、可复用 | 每次解析 |
| 初始化协商 | 协商后复用 | 更偏每次独立处理 |
| Streaming | 强支持 | 较弱 |
| 服务端通知 | 更自然 | 较难 |
| 多步骤流程 | 非常适合 | 实现麻烦 |
| 长任务 | 适合 | 不理想 |
| 部署难度 | 较高 | 较低 |
| 扩展性 | 需要考虑状态一致性 | 更容易水平扩展 |
| 典型场景 | Agent / Workflow / ChatGPT App | 简单 API 包装 |
为什么 MCP 一开始看起来是"有状态"的
MCP 一开始看起来偏有状态,主要是因为它的设计目标不是普通 API,而是持续协作。
1)它需要维护会话上下文
MCP 不只是"调用一个工具然后结束"。
它经常需要知道:
- 当前连接属于谁
- 前面做过哪些能力协商
- 当前执行到了哪一步
- 是否已经完成授权
- 是否已有可复用的上下文数据
如果这些内容全部不存,很多复杂交互都要由客户端重复传递,协议会变得很笨重。
2)它要支持服务端主动发消息
MCP 不只是等客户端请求再响应。
它常常还需要:
- 推送进度
- 推送通知
- 推送后续事件
- 在长任务中持续输出
这种模式天然更适合依赖会话,而不是每次都从零开始。
3)初始化协商本身就是状态
MCP 在建立连接后,往往要先完成初始化,例如:
- 支持哪些能力
- 支持哪些工具
- 支持哪些资源
- 支持哪些传输方式
- 授权与认证如何协同
协商完成以后,后续请求可以直接复用,不需要每次重来。
这本质上就是一种状态。
4)授权和资源访问也更适合绑定会话
在真实应用里,OAuth、资源访问权限、用户身份等信息,通常更容易和某个会话绑定。
这样可以减少:
- 重复校验
- 重复交换
- 重复构造上下文
也能让复杂场景更顺畅。
为什么现在又支持"无状态"
虽然 MCP 最初更偏有状态,但现代基础设施更偏爱无状态:
- API Gateway
- Serverless
- Edge Runtime
- Auto Scaling
- 多副本部署
- 云原生网关
这些环境都更适合无状态服务。
所以,MCP 在工程实践上也开始越来越支持:
- Stateless HTTP
- 纯请求驱动模式
- 更轻量的服务部署方式
这意味着:
MCP 的"协议思想"偏会话,
但 MCP 的"工程落地"可以无状态。
怎么选:有状态还是无状态
适合用有状态 MCP 的场景
如果你在做下面这些,优先考虑 Stateful:
- AI Agent 工具链
- 多步骤业务流程
- ChatGPT App 的复杂交互
- 文件处理、任务编排
- 长任务执行
- Streaming 输出
- 进度通知
- 服务端主动推送
- 需要恢复中间执行状态
典型例子
- "先查订单,再选择订单,再退款"
- "上传文件后做异步解析,再持续返回处理进度"
- "执行复杂工作流,中途调用多个工具"
- "一个会话里连续使用多个资源和工具"
适合用无状态 MCP 的场景
如果你在做下面这些,优先考虑 Stateless:
- 简单查询 API
- 单轮调用即完成
- 数据读取类工具
- 高并发简单接口
- 想快速部署到 serverless 或 edge
- 不需要通知、不需要长流程
典型例子
- "查天气"
- "查订单列表"
- "获取用户资料"
- "查询库存"
- "搜索文档摘要"
所以个人优选 无状态的mcp,毕竟基础设施简单,不过 ts的版本对于stateless支持还有问题 issue:Implement SEP-1442: Make MCP Stateless
而 c# 版本只需一句:
csharp
services.AddMcpServer().WithHttpTransport(options =>
{
options.Stateless = true;
})
当然目前还缺乏 mcp apps 的封装,不过基础还是比较简单,手写也能玩
c# mcp apps 怎么做
直接按照下面来就行,安装 以下库
xml
<ItemGroup>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
<PackageReference Include="Microsoft.OpenApi" Version="2.7.0" />
</ItemGroup>
Program.cs
csharp
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
services.AddControllers();
if (builder.Environment.IsDevelopmentOrDev())
{
services.AddOpenApi();
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
}
ervices.AddMcpServer().WithHttpTransport(options =>
{
options.Stateless = true;
})
.WithResources<WidgetResource>().WithTools<WigetTool>(); //mcp 定义
var app = builder.Build();
if (app.Environment.IsDevelopmentOrDev())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors(c => c.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
app.MapControllers();
app.UseHttpMetrics();
app.MapMetrics();
app.UseMiddleware<AppAuthorizationMiddleware>(); // 授权验证
app.MapMcp("mcp");
app.Run();
WidgetResource
csharp
[McpServerResourceType]
public class WidgetResource
{
private readonly bool isDevelopment;
private readonly IFileInfo file;
public WidgetResource()
{
this.file = new PhysicalFileProvider(Path.Combine(environment.ContentRootPath, "wwwroot")).GetFileInfo("/index.html"); // 对,ui 是 html, 你以为只用 c# ,做梦吧,梦里什么都有
}
[McpServerResource(MimeType = "text/html;profile=mcp-app", Name = "xxx-app", Title = "xxx-app", UriTemplate = "ui://widget/app.html")]
[McpMeta("openai/widgetPrefersBorder", false)]
[McpMeta("openai/widgetDomain", "xxxx")]
[McpMeta("openai/outputTemplate", "ui://widget/app.html")]
[McpMeta("openai/widgetAccessible", true)]
[McpMeta("openai/widgetCSP", """
{
"connect_domains": [
"https://xxx.com"
],
"resource_domains": [
]
}
""")]
public async ValueTask<string> Dashboard()
{
using var s = file.CreateReadStream();
return await s.ReadToEndAsync();
}
}
WigetTool
csharp
[McpServerToolType]
public class WigetTool
{
[AppAuth]
[McpServerTool(Name = "app"), Description(@"xxxx mcp app.")]
[McpMeta("openai/outputTemplate", "ui://widget/app.html")]
[McpMeta("openai/toolInvocation/invoking", "Loading app...")]
[McpMeta("openai/toolInvocation/invoked", "App loaded")]
[McpMeta("openai/widgetAccessible", true)]
[McpMeta("ui", JsonValue = """
{
"resourceUri":"ui://widget/app.html"
}
""")]
public static string GetApp()
{
return $"Loading";
}
}
UI package.json
json
{
"name": "ui",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"watch": "cross-env INPUT=index.html vite build --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.27.1",
"vue": "^3.5.30",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.5",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"tailwindcss": "^4.2.1",
"ts-to-zod": "^5.1.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vite-plugin-singlefile": "^2.3.2"
}
}
UI vite.config.ts
ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { viteSingleFile } from "vite-plugin-singlefile";
import tailwindcss from '@tailwindcss/vite'
const INPUT = process.env.INPUT;
if (!INPUT) {
throw new Error("INPUT environment variable is not set");
}
const isDevelopment = process.env.NODE_ENV === "development";
export default defineConfig({
plugins: [vue(),tailwindcss(), viteSingleFile()],
build: {
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: {
input: INPUT,
},
outDir: "../mcp/src/MCPServer/wwwroot",
emptyOutDir: false,
},
});
UI index.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Get Time App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>
UI mcp-app.ts
ts
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Get Time App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>
UI app.vue
vue
<script setup lang="ts">
import { ref, onMounted, watchEffect } from "vue";
import {
App,
applyDocumentTheme,
applyHostFonts,
applyHostStyleVariables,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
function extractTime(result: CallToolResult): string {
const { text } = result.content?.find((c) => c.type === "text")!;
return text;
}
const app = ref<App | null>(null);
const hostContext = ref<McpUiHostContext | undefined>();
const serverTime = ref("Loading...");
const messageText = ref("This is a messsssage text.");
const logText = ref("This is log text.");
const linkUrl = ref("https://modelcontextprotocol.io/");
// Apply host styles reactively when hostContext changes
watchEffect(() => {
const ctx = hostContext.value;
if (ctx?.theme) {
applyDocumentTheme(ctx.theme);
}
if (ctx?.styles?.variables) {
applyHostStyleVariables(ctx.styles.variables);
}
if (ctx?.styles?.css?.fonts) {
applyHostFonts(ctx.styles.css.fonts);
}
});
onMounted(async () => {
const instance = new App({ name: "Sellingpilot App", version: "1.0.0" });
instance.ontoolinput = (params) => {
console.info("Received tool call input:", params);
};
instance.ontoolresult = (result) => {
console.info("Received tool call result:", result);
serverTime.value = extractTime(result);
};
instance.ontoolcancelled = (params) => {
console.info("Tool call cancelled:", params.reason);
};
instance.onerror = console.error;
instance.onhostcontextchanged = (params) => {
hostContext.value = { ...hostContext.value, ...params };
};
await instance.connect();
app.value = instance;
hostContext.value = instance.getHostContext();
});
async function handleGetTime() {
if (!app.value) return;
try {
console.info("Calling get-time tool...");
const result = await app.value.callServerTool({ name: "get-time", arguments: {} });
console.info("get-time result:", result);
serverTime.value = extractTime(result);
} catch (e) {
console.error(e);
serverTime.value = "[ERROR]";
}
}
async function handleSendMessage() {
if (!app.value) return;
const signal = AbortSignal.timeout(5000);
try {
console.info("Sending message text to Host:", messageText.value);
const { isError } = await app.value.sendMessage(
{ role: "user", content: [{ type: "text", text: messageText.value }] },
{ signal },
);
console.info("Message", isError ? "rejected" : "accepted");
} catch (e) {
console.error("Message send error:", signal.aborted ? "timed out" : e);
}
}
async function handleSendLog() {
if (!app.value) return;
console.info("Sending log text to Host:", logText.value);
await app.value.sendLog({ level: "info", data: logText.value });
}
async function handleOpenLink() {
if (!app.value) return;
console.info("Sending open link request to Host:", linkUrl.value);
const { isError } = await app.value.openLink({ url: linkUrl.value });
console.info("Open link request", isError ? "rejected" : "accepted");
}
</script>
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div class="w-full max-w-md bg-white p-8 rounded-xl shadow-lg">
<h1 class="text-2xl font-semibold text-center text-gray-800 mb-6">
Vue 3 + Tailwind CSS 示例
</h1>
<!-- 输入框和按钮 -->
<div class="mb-4">
<label for="name" class="block text-sm font-medium text-gray-700">用户名</label>
<input
type="text"
id="name"
v-model="username"
placeholder="请输入用户名"
class="mt-2 w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
@click="submitForm"
class="w-full bg-blue-500 text-white py-2 rounded-md shadow-md hover:bg-blue-600 transition duration-200"
>
提交
</button>
<!-- 显示用户名 -->
<p v-if="submitted" class="mt-4 text-center text-gray-600">欢迎, {{ username }}!</p>
</div>
</div>
<main
class="main"
:style="hostContext?.safeAreaInsets && {
paddingTop: hostContext.safeAreaInsets.top + 'px',
paddingRight: hostContext.safeAreaInsets.right + 'px',
paddingBottom: hostContext.safeAreaInsets.bottom + 'px',
paddingLeft: hostContext.safeAreaInsets.left + 'px',
}"
>
<p class="notice">Watch activity in the DevTools console!</p>
<div class="action">
<p><strong>Server Time:</strong> <code class="server-time">{{ serverTime }}</code></p>
<button @click="handleGetTime">Get Server Time</button>
</div>
<div class="action">
<textarea v-model="messageText"></textarea>
<button @click="handleSendMessage">Send Message</button>
</div>
<div class="action">
<input type="text" v-model="logText">
<button @click="handleSendLog">Send Log</button>
</div>
<div class="action">
<input type="url" v-model="linkUrl">
<button @click="handleOpenLink">Open Link</button>
</div>
</main>
</template>
<style scoped>
</style>
基础只要这样,你可以在本地启动 ui 和 mcp 服务, 然后在 MCP Inspector 你可以非常方便测试
效果比如下图
mcp apps 授权
其实mcp apps 就是支持的 OAuth 2.1, 所以只要你搭建 OAuth 2.1标准的服务就行
当然csharp 这里没有现成的,相关还得我们自己来
首先 暴露元数据
这里方便演示,就直接写死了,实际当然你们得调整,(不想古法编程,就让ai 小龙虾动手嘛)
csharp
[Route("/mcp/.well-known")]
[Route("/.well-known")]
[ApiController]
public class WellKnownController : ControllerBase
{
[HttpGet("oauth-protected-resource")]
[HttpGet("oauth-protected-resource/mcp")]
public object GetOAuthProtectedResource()
{
return new
{
resource = "https://xxxxmcp",
authorization_servers = "https://xxxxmcp",
scopes_supported = new string[] { "xxxxmcp" }
};
}
[HttpGet("oauth-authorization-server")]
public object GetOAuthServer()
{
return new
{
issuer = "https://xxxxmcp",
authorization_endpoint = "https://xxxxmcp/oauth/authorize",
token_endpoint = "https://xxxxmcp/oauth/token",
registration_endpoint = "http://localhost:5048/mcp/.well-known/oauth-client-registration",
response_types_supported = new string[] { "code" },
grant_types_supported = new string[] { "authorization_code", "refresh_token" },
subject_types_supported = new string[] { "public" },
id_token_signing_alg_values_supported = new string[] { "RS256" },
scopes_supported = new string[] { "xxxxmcp" },
};
}
[HttpGet("oauth-client-registration")]
public object GetOAuthClientRegistration()
{
return new
{
client_id = "xxx",
client_secret = "xxx",
client_id_issued_at = DateTime.UtcNow.ToUnixTimestamp() / 1000,
client_secret_expires_at = 0,
};
}
}
最重要是这里Middleware 要按mcp apps 标准通知 mcp client 是需要验证的
csharp
public class AppAuthorizationMiddleware : IMiddleware
{
private readonly IRestApiClientFactory clientFactory;
public AppAuthorizationMiddleware(IRestApiClientFactory clientFactory)
{
this.clientFactory = clientFactory;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (context.Request.Path.StartsWithSegments("/mcp") && !context.Request.Path.StartsWithSegments("/mcp/.well-known"))
{
var h = context.Request.Headers.Authorization.ToString();
if (h.StartsWith("Bearer "))
{
h = h.Substring(7);
}
var invalid = h.IsNullOrEmpty();
//实际验证,比如jwt, 这里就不写了
if (invalid)
{
context.Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=http://localhost:5048/mcp/.well-known/oauth-protected-resource";
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { error = "invalid_token", error_description = "Authorization required or access token is invalid" });
return;
}
}
await next(context);
}
}
这样就够了,其用户数据权限什么的就是大家自己的业务逻辑了