Skip to content
Source
Sign inSign up
Open History to see the last commit

CodexMcpElicitation.ts

228 lines7.8 KB

1import type { ProviderApprovalDecision, ProviderApprovalOption } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import type * as EffectCodexSchema from "effect-codex-app-server/schema";
4​
5const NullableMcpElicitationString = Schema.NullOr(Schema.String);
6const McpElicitationMetadata = Schema.Struct({
7 app: Schema.optionalKey(NullableMcpElicitationString),
8 app_name: Schema.optionalKey(NullableMcpElicitationString),
9 appName: Schema.optionalKey(NullableMcpElicitationString),
10 connector_name: Schema.optionalKey(NullableMcpElicitationString),
11 connectorName: Schema.optionalKey(NullableMcpElicitationString),
12 allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)),
13 persist: Schema.optionalKey(
14 Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
15 ),
16 target: Schema.optionalKey(
17 Schema.NullOr(
18 Schema.Struct({
19 app: Schema.optionalKey(NullableMcpElicitationString),
20 name: Schema.optionalKey(NullableMcpElicitationString),
21 }),
22 ),
23 ),
24 tool_params: Schema.optionalKey(
25 Schema.NullOr(
26 Schema.Struct({
27 app: Schema.optionalKey(NullableMcpElicitationString),
28 app_name: Schema.optionalKey(NullableMcpElicitationString),
29 }),
30 ),
31 ),
32});
33const McpElicitationFormField = Schema.Struct({
34 type: Schema.optionalKey(NullableMcpElicitationString),
35 title: Schema.optionalKey(NullableMcpElicitationString),
36 description: Schema.optionalKey(NullableMcpElicitationString),
37 default: Schema.optionalKey(Schema.Json),
38 enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
39 enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
40 oneOf: Schema.optionalKey(
41 Schema.NullOr(
42 Schema.Array(
43 Schema.Struct({
44 const: Schema.String,
45 title: Schema.optionalKey(NullableMcpElicitationString),
46 }),
47 ),
48 ),
49 ),
50});
51const McpElicitationForm = Schema.Struct({
52 properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)),
53 required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
54});
55const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata);
56const isMcpElicitationForm = Schema.is(McpElicitationForm);
57​
58type McpElicitationPersistenceDecision = Extract<
59 ProviderApprovalDecision,
60 "acceptForSession" | "acceptAlways"
61>;
62​
63function mcpElicitationPersistenceDecision(
64 value: string,
65): McpElicitationPersistenceDecision | null {
66 const normalized = value.toLowerCase();
67 if (normalized.includes("session")) return "acceptForSession";
68 if (
69 normalized.includes("always") ||
70 normalized.includes("permanent") ||
71 normalized.includes("forever") ||
72 normalized.includes("persistent")
73 ) {
74 return "acceptAlways";
75 }
76 return null;
78​
79// Claude hands over the same MCP request with its own envelope; these are the
80// only fields the response is built from.
81type McpElicitationResponseInput = {
82 readonly mode: string;
83 readonly requestedSchema?: unknown;
84};
85​
86function mcpElicitationFormFields(payload: McpElicitationResponseInput) {
87 if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) {
88 return undefined;
89 }
90 return payload.requestedSchema;
92​
93function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) {
94 if (field.oneOf) {
95 return field.oneOf.map((option) => ({ value: option.const, label: option.title }));
96 }
97 return (field.enum ?? []).map((value, index) => ({
98 value,
99 label: field.enumNames?.[index],
100 }));
102​
103function isMcpElicitationPersistenceField(
104 key: string,
105 field: typeof McpElicitationFormField.Type,
106): boolean {
107 return (
108 mcpElicitationPersistenceDecision(key) !== null ||
109 key.toLowerCase() === "persist" ||
110 mcpElicitationPersistenceDecision(field.title ?? "") !== null ||
111 mcpElicitationPersistenceDecision(field.description ?? "") !== null
112 );
114​
115export function describeMcpElicitation(
116 payload: EffectCodexSchema.McpServerElicitationRequestParams,
117): { readonly appName: string; readonly options: ReadonlyArray<ProviderApprovalOption> } {
118 const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined;
119 const appName =
120 metadata?.app_name ??
121 metadata?.appName ??
122 metadata?.app ??
123 metadata?.target?.app ??
124 metadata?.target?.name ??
125 metadata?.tool_params?.app_name ??
126 metadata?.tool_params?.app ??
127 payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ??
128 metadata?.connector_name ??
129 metadata?.connectorName ??
130 payload.serverName;
131 const persistenceOptions = new Map<McpElicitationPersistenceDecision, string>();
132 const persist = metadata?.persist;
133 for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) {
134 const decision = mcpElicitationPersistenceDecision(value);
135 if (decision) persistenceOptions.set(decision, "");
136 }
137 if (metadata?.allowPersistentApproval) {
138 persistenceOptions.set("acceptAlways", "");
139 }
140​
141 const form = mcpElicitationFormFields(payload);
142 for (const [key, field] of Object.entries(form?.properties ?? {})) {
143 for (const option of mcpElicitationFieldOptions(field)) {
144 const decision = mcpElicitationPersistenceDecision(option.value);
145 if (decision) persistenceOptions.set(decision, option.label ?? "");
146 }
147 if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) {
148 persistenceOptions.set("acceptAlways", field.title ?? "");
149 }
150 }
151​
152 return {
153 appName,
154 options: [
155 { decision: "cancel", label: "Cancel" },
156 { decision: "decline", label: "Decline" },
157 ...(persistenceOptions.has("acceptForSession") &&
158 toMcpElicitationResponse(payload, "acceptForSession").action === "accept"
159 ? [
160 {
161 decision: "acceptForSession" as const,
162 label: persistenceOptions.get("acceptForSession") || "Always allow this session",
163 },
164 ]
165 : []),
166 ...(persistenceOptions.has("acceptAlways") &&
167 toMcpElicitationResponse(payload, "acceptAlways").action === "accept"
168 ? [
169 {
170 decision: "acceptAlways" as const,
171 label: persistenceOptions.get("acceptAlways") || "Always allow",
172 },
173 ]
174 : []),
175 { decision: "accept", label: "Approve" },
176 ],
177 };
179​
180export function toMcpElicitationResponse(
181 payload: McpElicitationResponseInput,
182 decision: ProviderApprovalDecision,
183): EffectCodexSchema.McpServerElicitationRequestResponse {
184 if (decision === "decline" || decision === "cancel") {
185 return { action: decision };
186 }
187​
188 if (payload.mode === "url") {
189 return { action: "decline" };
190 }
191​
192 const persist =
193 decision === "acceptForSession"
194 ? "session"
195 : decision === "acceptAlways"
196 ? "always"
197 : undefined;
198 const form = mcpElicitationFormFields(payload);
199 const content: Record<string, Schema.Json> = {};
200​
201 for (const [key, field] of Object.entries(form?.properties ?? {})) {
202 const options = mcpElicitationFieldOptions(field);
203 const chosenOption = options.find((option) =>
204 persist
205 ? mcpElicitationPersistenceDecision(option.value) === decision
206 : /^(?:once|accept|approve|allow)(?:[_\s-]*(?:once(?:[_\s-]*only)?|this[_\s-]*time))?$/i.test(
207 option.value.trim(),
208 ) && mcpElicitationPersistenceDecision(option.value) === null,
209 );
210 if (chosenOption) {
211 content[key] = chosenOption.value;
212 } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) {
213 content[key] = decision === "acceptAlways";
214 } else if (field.default !== undefined && field.default !== null) {
215 content[key] = field.default;
216 }
217 }
218​
219 if (form?.required?.some((key) => !Object.hasOwn(content, key))) {
220 return { action: "decline" };
221 }
222​
223 return {
224 action: "accept",
225 ...(persist ? { _meta: { persist } } : {}),
226 ...(form ? { content } : {}),
227 };