Skip to content

Commit 603043c

Browse files
author
fengjiayi
committed
feat: manage MCP API keys from web console
1 parent 9b17f23 commit 603043c

15 files changed

Lines changed: 1117 additions & 14 deletions

File tree

docs/mcp-readonly-server.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,19 @@ API key 应由 MCP 客户端的安全凭据、外部密钥注入或凭据存储
5050
插件配置、日志或流程内容中写入 key。
5151

5252
SereinFlow AI Toolkit 将 `SEREINFLOW_MCP_API_KEY` 作为 HTTP Bearer token 的
53-
环境变量名;插件只声明变量名,不存储或传输密钥值。开发环境首次启用时,服务运维者
54-
应为 `SereinFlow:Mcp:BootstrapAdminKey` 配置一个随机临时密钥,并在运行 Codex 的
55-
同一用户环境中把相同值设置为 `SEREINFLOW_MCP_API_KEY`。连接后,使用管理员 MCP
56-
工具创建一个专用 API key,把客户端环境变量替换为该专用 key,随后移除 Bootstrap
57-
配置。生产环境不应长期保留 Bootstrap key。
58-
59-
例如,PowerShell 中可在启动 API 与 Codex 前分别设置服务器和客户端环境变量;尖括号
53+
环境变量名;插件只声明变量名,不存储或传输密钥值。推荐在 Web Console 的“环境设置”
54+
中完成密钥管理:第一次打开时点击“生成首个密钥”,随后为 Codex 创建项目范围的
55+
客户端 key。完整 `sfk_...` Secret 只在生成或轮换后显示一次,服务端数据库只保存
56+
哈希和盐。初始化接口仅接受本机 loopback 请求;如果服务器和浏览器不在同一台机器,
57+
请先由服务器管理员通过受控配置完成首次初始化,再在 Web Console 中继续管理密钥。
58+
59+
生成的 Secret 需要填入 Codex 的 MCP 凭据配置中,变量名填写
60+
`SEREINFLOW_MCP_API_KEY`,变量值粘贴 Web Console 显示的完整 Secret。浏览器出于
61+
安全边界不能直接修改已经运行的 Codex 进程环境变量,因此服务端可以负责生成和持久化
62+
密钥,但客户端仍需要这一步凭据绑定。不要把 Secret 写入仓库、插件源文件、日志或流程
63+
内容。
64+
65+
只有在无法访问 Web Console 时,才使用 PowerShell 配置首次 bootstrap key;尖括号
6066
内容是同一段随机密钥,不能提交或记录:
6167

6268
```powershell
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { ProjectWorkspaceDto } from './flowApi'
2+
3+
export type McpPermission =
4+
| 'project.read'
5+
| 'project.write'
6+
| 'library.read'
7+
| 'run.read'
8+
| 'debug.read'
9+
| 'flow.write'
10+
| 'debug.control'
11+
| 'flow.publish'
12+
| 'flow.rollback'
13+
| 'script.compile'
14+
| 'library.import'
15+
| 'library.manage'
16+
| 'mcp.keys.manage'
17+
| 'sensitive.read'
18+
19+
export interface McpApiKeyDto {
20+
id: string
21+
projectId?: string | null
22+
name: string
23+
keyPrefix: string
24+
permissions: McpPermission[]
25+
createdAt: string
26+
expiresAt?: string | null
27+
revokedAt?: string | null
28+
lastUsedAt?: string | null
29+
isAdministrator: boolean
30+
}
31+
32+
export interface CreatedMcpApiKeyDto {
33+
key: McpApiKeyDto
34+
secret: string
35+
}
36+
37+
export interface RotatedMcpApiKeyDto {
38+
revokedKeyId: string
39+
key: McpApiKeyDto
40+
secret?: string | null
41+
}
42+
43+
export interface CreateMcpApiKeyRequestDto {
44+
projectId?: string | null
45+
name: string
46+
permissions: McpPermission[]
47+
expiresAt?: string | null
48+
isAdministrator?: boolean
49+
}
50+
51+
export interface McpKeyProjectOption {
52+
id: string
53+
name: string
54+
}
55+
56+
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
57+
const sessionCredentialKey = 'sereinflow.mcp.management-key'
58+
const rememberedCredentialKey = 'sereinflow.mcp.management-key.remembered'
59+
60+
export function getMcpManagementCredential(): string | undefined {
61+
if (typeof window === 'undefined') return undefined
62+
try {
63+
return window.sessionStorage.getItem(sessionCredentialKey)
64+
?? window.localStorage.getItem(rememberedCredentialKey)
65+
?? undefined
66+
} catch {
67+
return undefined
68+
}
69+
}
70+
71+
export function hasRememberedMcpManagementCredential(): boolean {
72+
if (typeof window === 'undefined') return false
73+
try {
74+
return Boolean(window.localStorage.getItem(rememberedCredentialKey))
75+
} catch {
76+
return false
77+
}
78+
}
79+
80+
export function storeMcpManagementCredential(secret: string, remember: boolean): void {
81+
if (typeof window === 'undefined') return
82+
try {
83+
window.sessionStorage.setItem(sessionCredentialKey, secret)
84+
if (remember) window.localStorage.setItem(rememberedCredentialKey, secret)
85+
else window.localStorage.removeItem(rememberedCredentialKey)
86+
} catch {
87+
// Storage may be disabled by the browser; the current request still succeeds.
88+
}
89+
}
90+
91+
export function clearMcpManagementCredential(): void {
92+
if (typeof window === 'undefined') return
93+
try {
94+
window.sessionStorage.removeItem(sessionCredentialKey)
95+
window.localStorage.removeItem(rememberedCredentialKey)
96+
} catch {
97+
// Ignore storage cleanup failures.
98+
}
99+
}
100+
101+
export async function setupMcpApiKey(): Promise<CreatedMcpApiKeyDto> {
102+
return request<CreatedMcpApiKeyDto>('/api/environment/settings/mcp-keys/setup', { method: 'POST' })
103+
}
104+
105+
export async function listMcpApiKeys(secret: string): Promise<McpApiKeyDto[]> {
106+
return request<McpApiKeyDto[]>('/api/environment/settings/mcp-keys', { secret })
107+
}
108+
109+
export async function createMcpApiKey(
110+
secret: string,
111+
body: CreateMcpApiKeyRequestDto,
112+
): Promise<CreatedMcpApiKeyDto> {
113+
return request<CreatedMcpApiKeyDto>('/api/environment/settings/mcp-keys', {
114+
method: 'POST',
115+
secret,
116+
body,
117+
})
118+
}
119+
120+
export async function rotateMcpApiKey(secret: string, keyId: string): Promise<RotatedMcpApiKeyDto> {
121+
return request<RotatedMcpApiKeyDto>(`/api/environment/settings/mcp-keys/${encodeURIComponent(keyId)}/rotate`, {
122+
method: 'POST',
123+
secret,
124+
})
125+
}
126+
127+
export async function revokeMcpApiKey(secret: string, keyId: string): Promise<McpApiKeyDto> {
128+
return request<McpApiKeyDto>(`/api/environment/settings/mcp-keys/${encodeURIComponent(keyId)}`, {
129+
method: 'DELETE',
130+
secret,
131+
})
132+
}
133+
134+
export function mcpKeyProjectOptions(workspaces: ProjectWorkspaceDto[]): McpKeyProjectOption[] {
135+
return workspaces
136+
.filter((workspace) => workspace.project.status !== 'archived')
137+
.map((workspace) => ({ id: workspace.project.id, name: workspace.project.name }))
138+
}
139+
140+
async function request<T>(
141+
path: string,
142+
options: { method?: 'POST' | 'DELETE'; secret?: string; body?: unknown } = {},
143+
): Promise<T> {
144+
const headers: Record<string, string> = {}
145+
if (options.body !== undefined) headers['content-type'] = 'application/json'
146+
if (options.secret) headers.authorization = `Bearer ${options.secret}`
147+
const response = await fetch(`${apiBaseUrl}${path}`, {
148+
method: options.method,
149+
headers: Object.keys(headers).length > 0 ? headers : undefined,
150+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
151+
})
152+
if (response.ok) {
153+
const payload = await response.text()
154+
return (payload ? JSON.parse(payload) : undefined) as T
155+
}
156+
157+
const problem = await response.json().catch(() => ({})) as { detail?: string; title?: string }
158+
throw new McpApiError(response.status, problem.detail ?? problem.title ?? `MCP request failed (${response.status}).`)
159+
}
160+
161+
export class McpApiError extends Error {
162+
public readonly status: number
163+
164+
constructor(status: number, message: string) {
165+
super(message)
166+
this.status = status
167+
this.name = 'McpApiError'
168+
}
169+
}

0 commit comments

Comments
 (0)