pi-mcp-adapter 2.21.0 → 2.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/config.ts +24 -13
- package/elicitation-handler.ts +27 -25
- package/host-html-template.ts +4 -4
- package/mcp-auth-flow.ts +0 -8
- package/mcp-auth.ts +120 -2
- package/package.json +5 -5
- package/proxy-modes.ts +20 -2
- package/sampling-handler.ts +8 -4
- package/server-manager.ts +4 -0
- package/ts-shape.ts +5 -1
- package/ui-resource-handler.ts +3 -7
- package/ui-server.ts +56 -27
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.21.2] - 2026-08-09
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Reported MCP servers still connecting after a zero-result tool search, so agents retry instead of treating the result as definitive. Thanks @Leon69924 for issue #316.
|
|
14
|
+
- Rejected malformed MCP config server entries and persisted OAuth credential records at their trust boundaries, so invalid local state fails before it reaches runtime connection or token code.
|
|
15
|
+
- Sized OAuth credential chunks below the Windows Credential Manager per-value limit, so oversized OAuth records persist on Windows instead of failing at every payload size. The previous 1800-character chunk size exceeded the 1280-character ceiling, which left the chunking added in #246 ineffective on Windows. Thanks @CrazyCoder for PR #318.
|
|
16
|
+
|
|
17
|
+
## [2.21.1] - 2026-08-08
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Refined MCP elicitation and sampling handler TypeScript contracts without changing runtime behavior.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Rendered closed JSON Schema object shapes that use `additionalProperties: false`. Thanks @giuseppecrj for PR #313.
|
|
24
|
+
- Stopped app-only MCP tool calls from triggering model turns or persisting as UI intents. Thanks @VikashLoomba for issue #314.
|
|
25
|
+
- Restored MCP sampling builds with current Pi AI releases by using its compatibility entry point. Thanks @eric-kansas for issue #308.
|
|
26
|
+
- Simplified empty MCP form elicitation to one confirmation dialog. Thanks @shardulbee for issue #309.
|
|
27
|
+
|
|
10
28
|
## [2.21.0] - 2026-08-06
|
|
11
29
|
|
|
12
30
|
### Added
|
package/README.md
CHANGED
|
@@ -438,7 +438,7 @@ Prompt results are flattened into one user message, preserving `[user]` and `[as
|
|
|
438
438
|
|
|
439
439
|
### MCP Elicitation
|
|
440
440
|
|
|
441
|
-
When Pi exposes dialog-capable UI, the adapter advertises form elicitation support. Forms use Pi's stock `select()` and `input()` dialogs, validate the response, and provide a review/edit step before submission. Explicit refusal maps to MCP `decline`; dismissing a dialog maps to `cancel`.
|
|
441
|
+
When Pi exposes dialog-capable UI, the adapter advertises form elicitation support. Forms use Pi's stock `select()` and `input()` dialogs, validate the response, and provide a review/edit step before submission. Empty forms use one confirmation dialog. Explicit refusal maps to MCP `decline`; dismissing a dialog maps to `cancel`.
|
|
442
442
|
|
|
443
443
|
URL mode is advertised only in TUI mode. The adapter displays the requesting server, target host, and full URL, and always requires consent before opening the browser. It also handles URL-required tool errors (`-32042`) and completion notifications; after completing the browser interaction, retry the original tool call.
|
|
444
444
|
|
package/config.ts
CHANGED
|
@@ -638,22 +638,32 @@ function readValidatedConfig(path: string, label: string): McpConfig | null {
|
|
|
638
638
|
}
|
|
639
639
|
|
|
640
640
|
function validateConfig(raw: unknown): McpConfig {
|
|
641
|
-
if (!raw
|
|
641
|
+
if (!isRecord(raw)) {
|
|
642
642
|
return { mcpServers: {} };
|
|
643
643
|
}
|
|
644
644
|
|
|
645
|
-
|
|
646
|
-
|
|
645
|
+
return {
|
|
646
|
+
mcpServers: toServerEntries(raw.mcpServers ?? raw["mcp-servers"]),
|
|
647
|
+
...(Array.isArray(raw.imports) ? { imports: raw.imports as ImportKind[] } : {}),
|
|
648
|
+
...(raw.settings !== undefined ? { settings: raw.settings as McpSettings } : {}),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
647
651
|
|
|
648
|
-
|
|
649
|
-
|
|
652
|
+
function toServerEntries(servers: unknown): Record<string, ServerEntry> {
|
|
653
|
+
if (!isRecord(servers)) return {};
|
|
654
|
+
const entries: Record<string, ServerEntry> = {};
|
|
655
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
656
|
+
if (isServerEntry(entry)) entries[name] = entry;
|
|
650
657
|
}
|
|
658
|
+
return entries;
|
|
659
|
+
}
|
|
651
660
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
661
|
+
function isServerEntry(value: unknown): value is ServerEntry {
|
|
662
|
+
return isRecord(value);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
666
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
657
667
|
}
|
|
658
668
|
|
|
659
669
|
function mergeOpenCodeConfigs(base: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -787,12 +797,13 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
|
|
|
787
797
|
continue;
|
|
788
798
|
}
|
|
789
799
|
|
|
790
|
-
if (
|
|
791
|
-
|
|
800
|
+
if (!isRecord(entry)) continue;
|
|
801
|
+
if (kind !== "codex") {
|
|
802
|
+
mappedServers[name] = entry;
|
|
792
803
|
continue;
|
|
793
804
|
}
|
|
794
805
|
|
|
795
|
-
const mapped = { ...
|
|
806
|
+
const mapped = { ...entry };
|
|
796
807
|
const bearerTokenEnv = mapped.bearer_token_env_var;
|
|
797
808
|
const httpHeaders = mapped.http_headers;
|
|
798
809
|
const envHttpHeaders = mapped.env_http_headers;
|
package/elicitation-handler.ts
CHANGED
|
@@ -14,8 +14,9 @@ import open from "open";
|
|
|
14
14
|
|
|
15
15
|
export type ElicitationValue = string | number | boolean | string[] | undefined;
|
|
16
16
|
type FormProperty = ElicitRequestFormParams["requestedSchema"]["properties"][string];
|
|
17
|
+
type FieldCollectionResult = { status: "cancelled" } | { status: "collected"; value: ElicitationValue };
|
|
17
18
|
|
|
18
|
-
export type ElicitationUIContext = ExtensionUIContext
|
|
19
|
+
export type ElicitationUIContext = Pick<ExtensionUIContext, "select" | "input" | "notify">;
|
|
19
20
|
|
|
20
21
|
export interface ElicitationHandlerOptions {
|
|
21
22
|
serverName: string;
|
|
@@ -44,18 +45,19 @@ export async function handleFormElicitation(
|
|
|
44
45
|
options: ElicitationHandlerOptions,
|
|
45
46
|
params: ElicitRequestFormParams,
|
|
46
47
|
): Promise<ElicitResult> {
|
|
48
|
+
const properties = Object.entries(params.requestedSchema.properties);
|
|
47
49
|
const decision = await options.ui.select(
|
|
48
50
|
`MCP Input Request\nServer: ${options.serverName}\n\n${params.message}`,
|
|
49
51
|
["Continue", "Decline"],
|
|
50
52
|
);
|
|
51
53
|
if (decision === undefined) return { action: "cancel" };
|
|
52
54
|
if (decision === "Decline") return { action: "decline" };
|
|
55
|
+
if (properties.length === 0) return { action: "accept", content: {} };
|
|
53
56
|
|
|
54
57
|
const values: Record<string, ElicitationValue> = {};
|
|
55
|
-
const properties = Object.entries(params.requestedSchema.properties);
|
|
56
58
|
for (const [name, schema] of properties) {
|
|
57
59
|
const value = await collectValidField(options.ui, params, name, schema);
|
|
58
|
-
if (
|
|
60
|
+
if (value.status === "cancelled") return { action: "cancel" };
|
|
59
61
|
values[name] = value.value;
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -63,7 +65,7 @@ export async function handleFormElicitation(
|
|
|
63
65
|
const content = coerceAndValidateFormValues(params, values);
|
|
64
66
|
const action = await options.ui.select(
|
|
65
67
|
formatReview(options.serverName, properties, content),
|
|
66
|
-
|
|
68
|
+
["Submit", "Edit", "Decline"],
|
|
67
69
|
);
|
|
68
70
|
if (action === undefined) return { action: "cancel" };
|
|
69
71
|
if (action === "Decline") return { action: "decline" };
|
|
@@ -76,7 +78,7 @@ export async function handleFormElicitation(
|
|
|
76
78
|
if (!property) continue;
|
|
77
79
|
const [name, schema] = property;
|
|
78
80
|
const value = await collectValidField(options.ui, params, name, schema, values[name]);
|
|
79
|
-
if (
|
|
81
|
+
if (value.status === "cancelled") return { action: "cancel" };
|
|
80
82
|
values[name] = value.value;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
@@ -87,11 +89,11 @@ async function collectValidField(
|
|
|
87
89
|
name: string,
|
|
88
90
|
schema: FormProperty,
|
|
89
91
|
current?: ElicitationValue,
|
|
90
|
-
): Promise<
|
|
92
|
+
): Promise<FieldCollectionResult> {
|
|
91
93
|
const required = params.requestedSchema.required?.includes(name) === true;
|
|
92
94
|
while (true) {
|
|
93
95
|
const result = await collectField(ui, params, name, schema, current);
|
|
94
|
-
if (
|
|
96
|
+
if (result.status === "cancelled") return result;
|
|
95
97
|
try {
|
|
96
98
|
coerceAndValidateFormValues({
|
|
97
99
|
...params,
|
|
@@ -115,7 +117,7 @@ async function collectField(
|
|
|
115
117
|
name: string,
|
|
116
118
|
schema: FormProperty,
|
|
117
119
|
current?: ElicitationValue,
|
|
118
|
-
): Promise<
|
|
120
|
+
): Promise<FieldCollectionResult> {
|
|
119
121
|
const required = params.requestedSchema.required?.includes(name) === true;
|
|
120
122
|
const title = [schema.title ?? humanizeName(name), required ? "(required)" : "", schema.description]
|
|
121
123
|
.filter(Boolean)
|
|
@@ -135,10 +137,10 @@ async function collectField(
|
|
|
135
137
|
const omit = required ? undefined : uniqueAction("Omit", actions);
|
|
136
138
|
if (omit) actions.push(omit);
|
|
137
139
|
const action = await ui.select(title, actions);
|
|
138
|
-
if (action === undefined) return {
|
|
139
|
-
if (action === useDefault) return {
|
|
140
|
-
if (action === omit) return {
|
|
141
|
-
return {
|
|
140
|
+
if (action === undefined) return { status: "cancelled" };
|
|
141
|
+
if (action === useDefault) return { status: "collected", value: schema.default };
|
|
142
|
+
if (action === omit) return { status: "collected", value: undefined };
|
|
143
|
+
return { status: "collected", value: choices[displays.indexOf(action)]?.value };
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
if (schema.type === "boolean") {
|
|
@@ -146,10 +148,10 @@ async function collectField(
|
|
|
146
148
|
if (schema.default !== undefined) actions.push("Use default");
|
|
147
149
|
if (!required) actions.push("Omit");
|
|
148
150
|
const action = await ui.select(title, actions);
|
|
149
|
-
if (action === undefined) return {
|
|
150
|
-
if (action === "Use default") return {
|
|
151
|
-
if (action === "Omit") return {
|
|
152
|
-
return {
|
|
151
|
+
if (action === undefined) return { status: "cancelled" };
|
|
152
|
+
if (action === "Use default") return { status: "collected", value: schema.default };
|
|
153
|
+
if (action === "Omit") return { status: "collected", value: undefined };
|
|
154
|
+
return { status: "collected", value: action === "Yes" };
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
if (schema.type === "array") {
|
|
@@ -157,9 +159,9 @@ async function collectField(
|
|
|
157
159
|
if (schema.default !== undefined) actions.push("Use default");
|
|
158
160
|
if (!required) actions.push("Omit");
|
|
159
161
|
const action = await ui.select(title, actions);
|
|
160
|
-
if (action === undefined) return {
|
|
161
|
-
if (action === "Use default") return {
|
|
162
|
-
if (action === "Omit") return {
|
|
162
|
+
if (action === undefined) return { status: "cancelled" };
|
|
163
|
+
if (action === "Use default") return { status: "collected", value: schema.default };
|
|
164
|
+
if (action === "Omit") return { status: "collected", value: undefined };
|
|
163
165
|
|
|
164
166
|
const choices = extractMultiSelectOptions(schema);
|
|
165
167
|
const selected = new Set(Array.isArray(current) ? current : []);
|
|
@@ -167,8 +169,8 @@ async function collectField(
|
|
|
167
169
|
const displays = uniqueLabels(choices.map(choice => selected.has(choice.value) ? `✓ ${choice.display}` : choice.display));
|
|
168
170
|
const done = uniqueAction("Done", displays);
|
|
169
171
|
const picked = await ui.select(title, [...displays, done]);
|
|
170
|
-
if (picked === undefined) return {
|
|
171
|
-
if (picked === done) return {
|
|
172
|
+
if (picked === undefined) return { status: "cancelled" };
|
|
173
|
+
if (picked === done) return { status: "collected", value: [...selected] };
|
|
172
174
|
const choice = choices[displays.indexOf(picked)];
|
|
173
175
|
if (!choice) continue;
|
|
174
176
|
if (selected.has(choice.value)) selected.delete(choice.value);
|
|
@@ -180,11 +182,11 @@ async function collectField(
|
|
|
180
182
|
if (schema.default !== undefined) actions.push("Use default");
|
|
181
183
|
if (!required) actions.push("Omit");
|
|
182
184
|
const action = await ui.select(title, actions);
|
|
183
|
-
if (action === undefined) return {
|
|
184
|
-
if (action === "Use default") return {
|
|
185
|
-
if (action === "Omit") return {
|
|
185
|
+
if (action === undefined) return { status: "cancelled" };
|
|
186
|
+
if (action === "Use default") return { status: "collected", value: schema.default };
|
|
187
|
+
if (action === "Omit") return { status: "collected", value: undefined };
|
|
186
188
|
const entered = await ui.input(title, current === undefined ? undefined : String(current));
|
|
187
|
-
return entered === undefined ? {
|
|
189
|
+
return entered === undefined ? { status: "cancelled" } : { status: "collected", value: entered };
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
export function coerceAndValidateFormValues(
|
package/host-html-template.ts
CHANGED
|
@@ -226,10 +226,10 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
|
|
|
226
226
|
}
|
|
227
227
|
const result = await post("/proxy/tools/call", params);
|
|
228
228
|
// Notify agent about the tool call
|
|
229
|
-
await post("/proxy/ui/
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
229
|
+
await post("/proxy/ui/generated-tool-call-intent", {
|
|
230
|
+
tool: params.name,
|
|
231
|
+
arguments: params.arguments,
|
|
232
|
+
isError: result.isError
|
|
233
233
|
}).catch(() => {});
|
|
234
234
|
return result;
|
|
235
235
|
};
|
package/mcp-auth-flow.ts
CHANGED
|
@@ -667,14 +667,12 @@ export async function authenticate(
|
|
|
667
667
|
}
|
|
668
668
|
|
|
669
669
|
const operation = (async (): Promise<AuthStatus> => {
|
|
670
|
-
// Start auth flow
|
|
671
670
|
const { authorizationUrl } = await startAuth(serverName, serverUrl, definition, {
|
|
672
671
|
...options,
|
|
673
672
|
...(signal ? { signal } : {}),
|
|
674
673
|
runtime,
|
|
675
674
|
})
|
|
676
675
|
|
|
677
|
-
// If no auth URL needed, already authenticated
|
|
678
676
|
if (!authorizationUrl) {
|
|
679
677
|
return "authenticated"
|
|
680
678
|
}
|
|
@@ -708,13 +706,11 @@ export async function authenticate(
|
|
|
708
706
|
console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
|
|
709
707
|
}
|
|
710
708
|
|
|
711
|
-
// Wait for callback
|
|
712
709
|
const callbackResult = await abortable(callbackPromise, signal)
|
|
713
710
|
|
|
714
711
|
// The callback server accepted only the flow-local reserved state.
|
|
715
712
|
throwIfAborted(signal)
|
|
716
713
|
|
|
717
|
-
// Complete the auth
|
|
718
714
|
return await completeAuth(serverName, callbackResult, {
|
|
719
715
|
...options,
|
|
720
716
|
...(signal ? { signal } : {}),
|
|
@@ -758,25 +754,21 @@ export async function getValidToken(
|
|
|
758
754
|
const authStorageOptions = options.authStorageOptions ?? {}
|
|
759
755
|
const signal = combineAbortSignals(runtime.signal, options.signal)
|
|
760
756
|
throwIfAborted(signal)
|
|
761
|
-
// Check if we have valid tokens
|
|
762
757
|
const entry = await getAuthForUrl(serverName, serverUrl, authStorageOptions)
|
|
763
758
|
throwIfAborted(signal)
|
|
764
759
|
if (!entry?.tokens) {
|
|
765
760
|
return null
|
|
766
761
|
}
|
|
767
762
|
|
|
768
|
-
// Check expiration
|
|
769
763
|
const expired = await isTokenExpired(serverName, authStorageOptions)
|
|
770
764
|
if (expired === false) {
|
|
771
765
|
return entry.tokens
|
|
772
766
|
}
|
|
773
767
|
|
|
774
768
|
if (expired === true && entry.tokens.refreshToken) {
|
|
775
|
-
// Token is expired, try to refresh
|
|
776
769
|
console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`)
|
|
777
770
|
|
|
778
771
|
try {
|
|
779
|
-
// Create auth provider for token refresh
|
|
780
772
|
const authProvider = new McpOAuthProvider(serverName, serverUrl, {}, {
|
|
781
773
|
onRedirect: async () => {},
|
|
782
774
|
}, authStorageOptions, runtime.signal)
|
package/mcp-auth.ts
CHANGED
|
@@ -22,7 +22,16 @@ import { resolveConfiguredOAuthDir } from './config.ts';
|
|
|
22
22
|
const require = createRequire(import.meta.url);
|
|
23
23
|
const AUTH_SECRET_SERVICE = 'pi-mcp-adapter.oauth';
|
|
24
24
|
const TEST_AUTH_STORE_ENV = 'PI_MCP_ADAPTER_TEST_AUTH_STORE';
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Windows Credential Manager caps one value at CRED_MAX_CREDENTIAL_BLOB_SIZE
|
|
27
|
+
* (2560 bytes) and stores it as UTF-16, so the real ceiling is
|
|
28
|
+
* AUTH_SECRET_VALUE_LIMIT characters. Chunks must stay below that, and so must
|
|
29
|
+
* the threshold that decides whether to chunk at all, or oversized records still
|
|
30
|
+
* fail to persist on Windows.
|
|
31
|
+
*/
|
|
32
|
+
const AUTH_SECRET_CHUNK_SIZE = 1000;
|
|
33
|
+
/** Largest single value the strictest supported credential store accepts. */
|
|
34
|
+
const AUTH_SECRET_VALUE_LIMIT = 1280;
|
|
26
35
|
const KEYRING_RECOVERY_DISABLED_ENV = 'PI_MCP_ADAPTER_DISABLE_KEYRING_RECOVERY';
|
|
27
36
|
const KEYRING_RECOVERY_KEYCTL_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_KEYCTL';
|
|
28
37
|
const KEYRING_RECOVERY_NODE_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_NODE';
|
|
@@ -163,6 +172,22 @@ const keyringAuthSecretStore: AuthSecretStore = {
|
|
|
163
172
|
},
|
|
164
173
|
};
|
|
165
174
|
|
|
175
|
+
/** Mimics the Windows Credential Manager per-value ceiling for tests. */
|
|
176
|
+
const sizeLimitedAuthSecretStore: AuthSecretStore = {
|
|
177
|
+
read(account) {
|
|
178
|
+
return memoryAuthEntries.get(account);
|
|
179
|
+
},
|
|
180
|
+
write(account, payload) {
|
|
181
|
+
if (payload.length > AUTH_SECRET_VALUE_LIMIT) {
|
|
182
|
+
throw new Error(`Value of 'password encoded as UTF-16' is longer than the platform limit of ${AUTH_SECRET_VALUE_LIMIT * 2} chars`);
|
|
183
|
+
}
|
|
184
|
+
memoryAuthEntries.set(account, payload);
|
|
185
|
+
},
|
|
186
|
+
remove(account) {
|
|
187
|
+
memoryAuthEntries.delete(account);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
166
191
|
const unavailableAuthSecretStore: AuthSecretStore = {
|
|
167
192
|
read() {
|
|
168
193
|
throw new Error('simulated secure credential store unavailable');
|
|
@@ -205,6 +230,7 @@ export function removeTestAuthSecretStoreEntry(account: string): void {
|
|
|
205
230
|
|
|
206
231
|
function getAuthSecretStore(): AuthSecretStore {
|
|
207
232
|
if (process.env[TEST_AUTH_STORE_ENV] === 'memory') return memoryAuthSecretStore;
|
|
233
|
+
if (process.env[TEST_AUTH_STORE_ENV] === 'sizelimited') return sizeLimitedAuthSecretStore;
|
|
208
234
|
if (process.env[TEST_AUTH_STORE_ENV] === 'unavailable') return unavailableAuthSecretStore;
|
|
209
235
|
if (process.env[TEST_AUTH_STORE_ENV] === 'keyrevoked') return keyRevokedAuthSecretStore;
|
|
210
236
|
return keyringAuthSecretStore;
|
|
@@ -401,7 +427,99 @@ function parseJsonPayload(serverName: string, payload: string, source: string):
|
|
|
401
427
|
}
|
|
402
428
|
|
|
403
429
|
function parseAuthEntryPayload(serverName: string, payload: string, source: string): AuthEntry {
|
|
404
|
-
|
|
430
|
+
const parsed = parseJsonPayload(serverName, payload, source);
|
|
431
|
+
const entry = toAuthEntry(parsed);
|
|
432
|
+
if (!entry) {
|
|
433
|
+
throw new Error(`Failed to parse OAuth credentials for ${serverName} from ${source}: invalid credential shape`);
|
|
434
|
+
}
|
|
435
|
+
return entry;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function toAuthEntry(value: unknown): AuthEntry | undefined {
|
|
439
|
+
const entry = toRecord(value);
|
|
440
|
+
if (!entry) return undefined;
|
|
441
|
+
|
|
442
|
+
const codeVerifier = optionalString(entry.codeVerifier);
|
|
443
|
+
const oauthState = optionalString(entry.oauthState);
|
|
444
|
+
const serverUrl = optionalString(entry.serverUrl);
|
|
445
|
+
if (codeVerifier === null || oauthState === null || serverUrl === null) return undefined;
|
|
446
|
+
|
|
447
|
+
const tokens = entry.tokens === undefined ? undefined : toStoredTokens(entry.tokens);
|
|
448
|
+
const clientInfo = entry.clientInfo === undefined ? undefined : toStoredClientInfo(entry.clientInfo);
|
|
449
|
+
if ((entry.tokens !== undefined && !tokens) || (entry.clientInfo !== undefined && !clientInfo)) return undefined;
|
|
450
|
+
|
|
451
|
+
const authEntry: AuthEntry = {};
|
|
452
|
+
if (tokens) authEntry.tokens = tokens;
|
|
453
|
+
if (clientInfo) authEntry.clientInfo = clientInfo;
|
|
454
|
+
if (codeVerifier !== undefined) authEntry.codeVerifier = codeVerifier;
|
|
455
|
+
if (oauthState !== undefined) authEntry.oauthState = oauthState;
|
|
456
|
+
if (serverUrl !== undefined) authEntry.serverUrl = serverUrl;
|
|
457
|
+
return authEntry;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function toStoredTokens(value: unknown): StoredTokens | undefined {
|
|
461
|
+
const tokens = toRecord(value);
|
|
462
|
+
if (!tokens || typeof tokens.accessToken !== 'string') return undefined;
|
|
463
|
+
|
|
464
|
+
const refreshToken = optionalString(tokens.refreshToken);
|
|
465
|
+
const scope = optionalString(tokens.scope);
|
|
466
|
+
const issuer = optionalString(tokens.issuer);
|
|
467
|
+
const expiresAt = optionalNumber(tokens.expiresAt);
|
|
468
|
+
if (refreshToken === null || scope === null || issuer === null || expiresAt === null) return undefined;
|
|
469
|
+
|
|
470
|
+
const storedTokens: StoredTokens = { accessToken: tokens.accessToken };
|
|
471
|
+
if (refreshToken !== undefined) storedTokens.refreshToken = refreshToken;
|
|
472
|
+
if (expiresAt !== undefined) storedTokens.expiresAt = expiresAt;
|
|
473
|
+
if (scope !== undefined) storedTokens.scope = scope;
|
|
474
|
+
if (issuer !== undefined) storedTokens.issuer = issuer;
|
|
475
|
+
return storedTokens;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function toStoredClientInfo(value: unknown): StoredClientInfo | undefined {
|
|
479
|
+
const clientInfo = toRecord(value);
|
|
480
|
+
if (!clientInfo || typeof clientInfo.clientId !== 'string') return undefined;
|
|
481
|
+
|
|
482
|
+
const clientSecret = optionalString(clientInfo.clientSecret);
|
|
483
|
+
const issuer = optionalString(clientInfo.issuer);
|
|
484
|
+
const clientIdIssuedAt = optionalNumber(clientInfo.clientIdIssuedAt);
|
|
485
|
+
const clientSecretExpiresAt = optionalNumber(clientInfo.clientSecretExpiresAt);
|
|
486
|
+
const configPreRegistered = optionalBoolean(clientInfo.configPreRegistered);
|
|
487
|
+
if (clientSecret === null || issuer === null || clientIdIssuedAt === null || clientSecretExpiresAt === null || configPreRegistered === null) return undefined;
|
|
488
|
+
|
|
489
|
+
const storedClient: StoredClientInfo = { clientId: clientInfo.clientId };
|
|
490
|
+
const redirectUris = stringArray(clientInfo.redirectUris);
|
|
491
|
+
if (clientSecret !== undefined) storedClient.clientSecret = clientSecret;
|
|
492
|
+
if (clientIdIssuedAt !== undefined) storedClient.clientIdIssuedAt = clientIdIssuedAt;
|
|
493
|
+
if (clientSecretExpiresAt !== undefined) storedClient.clientSecretExpiresAt = clientSecretExpiresAt;
|
|
494
|
+
if (redirectUris !== undefined) storedClient.redirectUris = redirectUris;
|
|
495
|
+
if (issuer !== undefined) storedClient.issuer = issuer;
|
|
496
|
+
if (configPreRegistered !== undefined) storedClient.configPreRegistered = configPreRegistered;
|
|
497
|
+
return storedClient;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
|
501
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
502
|
+
? value as Record<string, unknown>
|
|
503
|
+
: undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function optionalString(value: unknown): string | null | undefined {
|
|
507
|
+
if (value === undefined) return undefined;
|
|
508
|
+
return typeof value === 'string' ? value : null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function optionalNumber(value: unknown): number | null | undefined {
|
|
512
|
+
if (value === undefined) return undefined;
|
|
513
|
+
return typeof value === 'number' ? value : null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function optionalBoolean(value: unknown): boolean | null | undefined {
|
|
517
|
+
if (value === undefined) return undefined;
|
|
518
|
+
return typeof value === 'boolean' ? value : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
522
|
+
return Array.isArray(value) && value.every(uri => typeof uri === 'string') ? value : undefined;
|
|
405
523
|
}
|
|
406
524
|
|
|
407
525
|
function isAuthEntryChunkManifest(value: unknown): value is AuthEntryChunkManifest {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mcp-adapter",
|
|
3
|
-
"version": "2.21.
|
|
3
|
+
"version": "2.21.2",
|
|
4
4
|
"description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.ts",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"zod": "^3.25.0 || ^4.0.0"
|
|
140
140
|
},
|
|
141
141
|
"peerDependencies": {
|
|
142
|
-
"@earendil-works/pi-ai": "
|
|
142
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
143
143
|
"@earendil-works/pi-tui": "*",
|
|
144
144
|
"typebox": "*",
|
|
145
145
|
"zod": "^3.25.0 || ^4.0.0"
|
|
@@ -156,9 +156,9 @@
|
|
|
156
156
|
}
|
|
157
157
|
},
|
|
158
158
|
"devDependencies": {
|
|
159
|
-
"@earendil-works/pi-ai": "0.
|
|
160
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
161
|
-
"@earendil-works/pi-tui": "0.
|
|
159
|
+
"@earendil-works/pi-ai": "0.84.1",
|
|
160
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
161
|
+
"@earendil-works/pi-tui": "0.84.1",
|
|
162
162
|
"@modelcontextprotocol/conformance": "0.1.16",
|
|
163
163
|
"@types/bun": "^1.0.0",
|
|
164
164
|
"@types/cross-spawn": "6.0.6",
|
package/proxy-modes.ts
CHANGED
|
@@ -525,10 +525,28 @@ export function executeSearch(
|
|
|
525
525
|
|
|
526
526
|
const page = paginate(matches, offset, limit);
|
|
527
527
|
if (page.total === 0) {
|
|
528
|
+
const connectingServers = server
|
|
529
|
+
? state.config.mcpServers[server] && state.manager.isConnecting(server) ? [server] : []
|
|
530
|
+
: Object.keys(state.config.mcpServers)
|
|
531
|
+
.filter(name => !isServerDisabled(state.config.mcpServers[name]) && state.manager.isConnecting(name))
|
|
532
|
+
.sort((a, b) => a.localeCompare(b));
|
|
528
533
|
const msg = server ? `No tools matching "${query}" in "${server}"` : `No tools matching "${query}"`;
|
|
534
|
+
const connectingMessage = connectingServers.length === 1
|
|
535
|
+
? ` Server "${connectingServers[0]}" is still connecting; retry in a moment.`
|
|
536
|
+
: connectingServers.length > 1
|
|
537
|
+
? ` Servers ${connectingServers.map(name => `"${name}"`).join(", ")} are still connecting; retry in a moment.`
|
|
538
|
+
: "";
|
|
529
539
|
return {
|
|
530
|
-
content: [{ type: "text" as const, text: msg }],
|
|
531
|
-
details: {
|
|
540
|
+
content: [{ type: "text" as const, text: `${msg}${connectingMessage}` }],
|
|
541
|
+
details: {
|
|
542
|
+
mode: "search",
|
|
543
|
+
matches: [],
|
|
544
|
+
count: 0,
|
|
545
|
+
hasMore: false,
|
|
546
|
+
nextOffset: null,
|
|
547
|
+
query,
|
|
548
|
+
...(connectingServers.length > 0 ? { connectingServers } : {}),
|
|
549
|
+
},
|
|
532
550
|
};
|
|
533
551
|
}
|
|
534
552
|
|
package/sampling-handler.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { complete
|
|
1
|
+
import { complete } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type { Api, AssistantMessage, Message, Model, ProviderHeaders, TextContent } from "@earendil-works/pi-ai";
|
|
2
3
|
import { truncateAtWord } from "./utils.ts";
|
|
3
4
|
import { throwIfAborted } from "./abort.ts";
|
|
4
5
|
import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
@@ -11,11 +12,14 @@ import {
|
|
|
11
12
|
type SamplingMessageContentBlock,
|
|
12
13
|
} from "@modelcontextprotocol/client";
|
|
13
14
|
|
|
15
|
+
export type SamplingUIContext = Pick<ExtensionUIContext, "confirm">;
|
|
16
|
+
export type SamplingModelRegistry = Pick<ModelRegistry, "getAvailable" | "getApiKeyAndHeaders">;
|
|
17
|
+
|
|
14
18
|
export interface SamplingHandlerOptions {
|
|
15
19
|
serverName: string;
|
|
16
20
|
autoApprove: boolean;
|
|
17
|
-
ui?:
|
|
18
|
-
modelRegistry:
|
|
21
|
+
ui?: SamplingUIContext;
|
|
22
|
+
modelRegistry: SamplingModelRegistry;
|
|
19
23
|
getCurrentModel: () => Model<Api> | undefined;
|
|
20
24
|
getSignal: () => AbortSignal | undefined;
|
|
21
25
|
}
|
|
@@ -126,7 +130,7 @@ async function resolveSamplingModel(
|
|
|
126
130
|
): Promise<{
|
|
127
131
|
model: Model<Api>;
|
|
128
132
|
apiKey?: string;
|
|
129
|
-
headers?:
|
|
133
|
+
headers?: ProviderHeaders;
|
|
130
134
|
}> {
|
|
131
135
|
const candidates: Model<Api>[] = [];
|
|
132
136
|
const availableModels = options.modelRegistry.getAvailable();
|
package/server-manager.ts
CHANGED
|
@@ -1030,6 +1030,10 @@ export class McpServerManager {
|
|
|
1030
1030
|
return false;
|
|
1031
1031
|
}
|
|
1032
1032
|
|
|
1033
|
+
isConnecting(name: string): boolean {
|
|
1034
|
+
return this.connectPromises.has(name);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1033
1037
|
getConnection(name: string): ServerConnection | undefined {
|
|
1034
1038
|
return this.connections.get(name);
|
|
1035
1039
|
}
|
package/ts-shape.ts
CHANGED
|
@@ -111,7 +111,11 @@ function isSchema(value: unknown): value is Schema {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
function hasUnsupportedKeyword(schema: Schema): boolean {
|
|
114
|
-
return UNSUPPORTED_KEYWORDS.some(keyword =>
|
|
114
|
+
return UNSUPPORTED_KEYWORDS.some(keyword => {
|
|
115
|
+
if (!Object.hasOwn(schema, keyword)) return false;
|
|
116
|
+
// `additionalProperties: false` is a closed-object constraint, not a shape that this renderer needs to understand.
|
|
117
|
+
return keyword !== "additionalProperties" || schema.additionalProperties !== false;
|
|
118
|
+
});
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
function decodePointerToken(token: string): string {
|
package/ui-resource-handler.ts
CHANGED
|
@@ -179,10 +179,10 @@ function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMet
|
|
|
179
179
|
|
|
180
180
|
const ui = isRecord(meta.ui) ? meta.ui : undefined;
|
|
181
181
|
const out: UiResourceMeta = {};
|
|
182
|
-
const openAiCsp =
|
|
182
|
+
const openAiCsp = Object.hasOwn(meta, "openai/widgetCSP")
|
|
183
183
|
? normalizeOpenAiWidgetCsp(meta["openai/widgetCSP"])
|
|
184
184
|
: undefined;
|
|
185
|
-
const hasStandardCsp = !!ui &&
|
|
185
|
+
const hasStandardCsp = !!ui && Object.hasOwn(ui, "csp");
|
|
186
186
|
const standardCspValue = hasStandardCsp ? ui.csp : undefined;
|
|
187
187
|
|
|
188
188
|
if (hasStandardCsp && !isRecord(standardCspValue)) {
|
|
@@ -196,7 +196,7 @@ function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMet
|
|
|
196
196
|
out.csp = { ...openAiCsp, ...standardCsp };
|
|
197
197
|
if (isRecord(standardCspValue)) {
|
|
198
198
|
for (const [, standardField] of OPENAI_CSP_FIELD_MAPPINGS) {
|
|
199
|
-
if (
|
|
199
|
+
if (Object.hasOwn(standardCspValue, standardField) && !copyStringArray(standardCspValue[standardField])) {
|
|
200
200
|
delete out.csp[standardField];
|
|
201
201
|
}
|
|
202
202
|
}
|
|
@@ -245,10 +245,6 @@ function copyStringArray(value: unknown): string[] | undefined {
|
|
|
245
245
|
: undefined;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
function hasOwnProperty(record: Record<string, unknown>, property: string): boolean {
|
|
249
|
-
return Object.prototype.hasOwnProperty.call(record, property);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
248
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
253
249
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
254
250
|
}
|
package/ui-server.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { McpServerManager } from "./server-manager.ts";
|
|
|
17
17
|
import type { McpExtensionState } from "./state.ts";
|
|
18
18
|
import { SessionRecoveryAuthRequiredError, withSessionRecovery, type SessionRecoveryDeps } from "./session-recovery.ts";
|
|
19
19
|
import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.ts";
|
|
20
|
-
import { extractUiToolVisibility, isUiToolCallableByApp } from "./ui-tool-visibility.ts";
|
|
20
|
+
import { extractUiToolVisibility, isUiToolCallableByApp, isUiToolVisibleToModel } from "./ui-tool-visibility.ts";
|
|
21
21
|
import {
|
|
22
22
|
createUiModelContextUpdate,
|
|
23
23
|
extractUiPromptText,
|
|
@@ -145,6 +145,41 @@ export async function startUiServer(options: UiServerOptions): Promise<UiServerH
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
const isAppOnlyTool = (toolName: string): boolean => {
|
|
149
|
+
const toolDefinition = options.manager.getConnection(options.serverName)?.tools?.find((tool) => tool.name === toolName);
|
|
150
|
+
if (!toolDefinition) return false;
|
|
151
|
+
const visibility = extractUiToolVisibility(toolDefinition._meta);
|
|
152
|
+
return isUiToolCallableByApp(visibility) && !isUiToolVisibleToModel(visibility);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const recordUiMessage = async (msgParams: UiMessageParams): Promise<void> => {
|
|
156
|
+
const promptText = extractUiPromptText(msgParams);
|
|
157
|
+
|
|
158
|
+
// Track messages by type (order: prompt → intent → notify)
|
|
159
|
+
// Must match the order in index.ts onMessage handler
|
|
160
|
+
if (promptText) {
|
|
161
|
+
sessionMessages.prompts.push(promptText);
|
|
162
|
+
log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
|
|
163
|
+
} else if (msgParams.type === "intent" || msgParams.intent) {
|
|
164
|
+
const intentName = msgParams.intent ?? "";
|
|
165
|
+
if (intentName) {
|
|
166
|
+
sessionMessages.intents.push({
|
|
167
|
+
intent: intentName,
|
|
168
|
+
...(msgParams.params !== undefined ? { params: msgParams.params } : {}),
|
|
169
|
+
});
|
|
170
|
+
log.debug("UI intent received", { intent: intentName });
|
|
171
|
+
}
|
|
172
|
+
} else if (msgParams.type === "notify" || msgParams.message) {
|
|
173
|
+
const notifyText = msgParams.message ?? "";
|
|
174
|
+
if (notifyText) {
|
|
175
|
+
sessionMessages.notifications.push(notifyText);
|
|
176
|
+
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await options.onMessage?.(msgParams);
|
|
181
|
+
};
|
|
182
|
+
|
|
148
183
|
const touchHeartbeat = () => {
|
|
149
184
|
lastHeartbeatAt = Date.now();
|
|
150
185
|
};
|
|
@@ -476,33 +511,27 @@ export async function startUiServer(options: UiServerOptions): Promise<UiServerH
|
|
|
476
511
|
return;
|
|
477
512
|
}
|
|
478
513
|
|
|
479
|
-
if (url.pathname === "/proxy/ui/
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
...(msgParams.params !== undefined ? { params: msgParams.params } : {}),
|
|
494
|
-
});
|
|
495
|
-
log.debug("UI intent received", { intent: intentName });
|
|
496
|
-
}
|
|
497
|
-
} else if (msgParams.type === "notify" || msgParams.message) {
|
|
498
|
-
const notifyText = msgParams.message ?? "";
|
|
499
|
-
if (notifyText) {
|
|
500
|
-
sessionMessages.notifications.push(notifyText);
|
|
501
|
-
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
|
502
|
-
}
|
|
514
|
+
if (url.pathname === "/proxy/ui/generated-tool-call-intent") {
|
|
515
|
+
const tool = typeof params.tool === "string" ? params.tool : undefined;
|
|
516
|
+
if (tool && isAppOnlyTool(tool)) {
|
|
517
|
+
log.debug("Ignored generated app-only tool call intent", { tool });
|
|
518
|
+
} else {
|
|
519
|
+
await recordUiMessage({
|
|
520
|
+
type: "intent",
|
|
521
|
+
intent: "call_tool",
|
|
522
|
+
params: {
|
|
523
|
+
...(tool !== undefined ? { tool } : {}),
|
|
524
|
+
...(params.arguments !== undefined ? { arguments: params.arguments } : {}),
|
|
525
|
+
...(params.isError !== undefined ? { isError: params.isError } : {}),
|
|
526
|
+
},
|
|
527
|
+
});
|
|
503
528
|
}
|
|
504
|
-
|
|
505
|
-
|
|
529
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (url.pathname === "/proxy/ui/message") {
|
|
534
|
+
await recordUiMessage(params as UiMessageParams);
|
|
506
535
|
sendJson(res, 200, { ok: true, result: {} });
|
|
507
536
|
return;
|
|
508
537
|
}
|