@oh-my-pi/pi-coding-agent 16.1.20 → 16.1.22
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 +17 -0
- package/dist/cli.js +2284 -2272
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +42 -5
- package/dist/types/mcp/transports/stdio.test.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +32 -0
- package/dist/types/modes/components/tool-execution.d.ts +5 -5
- package/dist/types/modes/controllers/event-controller.d.ts +10 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -1
- package/dist/types/utils/clipboard.d.ts +10 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
- package/src/advisor/__tests__/advisor.test.ts +44 -0
- package/src/advisor/advise-tool.ts +33 -0
- package/src/autolearn/controller.ts +17 -2
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
- package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
- package/src/mcp/manager.ts +12 -3
- package/src/mcp/oauth-discovery.ts +48 -1
- package/src/mcp/oauth-flow.ts +121 -7
- package/src/mcp/transports/stdio.test.ts +28 -0
- package/src/mcp/transports/stdio.ts +5 -2
- package/src/modes/components/chat-transcript-builder.ts +31 -0
- package/src/modes/components/custom-editor.test.ts +80 -0
- package/src/modes/components/custom-editor.ts +86 -6
- package/src/modes/components/tool-execution.ts +50 -25
- package/src/modes/controllers/event-controller.ts +57 -8
- package/src/modes/controllers/input-controller.ts +70 -27
- package/src/modes/controllers/mcp-command-controller.ts +18 -2
- package/src/modes/utils/ui-helpers.ts +40 -0
- package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
- package/src/prompts/system/autolearn-nudge.md +4 -2
- package/src/prompts/tools/todo.md +1 -1
- package/src/session/agent-session.ts +4 -0
- package/src/tools/todo.ts +20 -10
- package/src/utils/clipboard.ts +57 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { isBuiltin } from "node:module";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import * as url from "node:url";
|
|
4
5
|
import { isCompiledBinary } from "@oh-my-pi/pi-utils";
|
|
@@ -456,10 +457,11 @@ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'
|
|
|
456
457
|
|
|
457
458
|
/**
|
|
458
459
|
* Rewrite the extension-owned specifiers OMP must host-resolve — legacy
|
|
459
|
-
* `@(scope)/pi-*`, bare TypeBox packages,
|
|
460
|
-
* `#src
|
|
461
|
-
*
|
|
462
|
-
*
|
|
460
|
+
* `@(scope)/pi-*`, bare TypeBox packages, package `imports` aliases like
|
|
461
|
+
* `#src/*`, and extension-local bare dependencies — to absolute `file://` URLs
|
|
462
|
+
* or compiled-mode virtual specifiers. Relative siblings and built-in modules
|
|
463
|
+
* are left untouched so Bun resolves them from the extension's real on-disk
|
|
464
|
+
* location.
|
|
463
465
|
*/
|
|
464
466
|
async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
|
|
465
467
|
const withPi = rewriteLegacyPiImports(source);
|
|
@@ -474,7 +476,12 @@ async function rewriteLegacyExtensionSource(source: string, importerPath: string
|
|
|
474
476
|
`${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`,
|
|
475
477
|
)
|
|
476
478
|
: withPi;
|
|
477
|
-
return rewriteExtensionPackageImports(withTypeBox, importerPath);
|
|
479
|
+
return rewriteExtensionBareImports(await rewriteExtensionPackageImports(withTypeBox, importerPath), importerPath);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Test seam for compiled-binary legacy extension source rewriting. */
|
|
483
|
+
export async function __rewriteLegacyExtensionSourceForTests(source: string, importerPath: string): Promise<string> {
|
|
484
|
+
return rewriteLegacyExtensionSource(source, importerPath);
|
|
478
485
|
}
|
|
479
486
|
|
|
480
487
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -688,6 +695,187 @@ async function rewriteExtensionPackageImports(source: string, importerPath: stri
|
|
|
688
695
|
return `${rewritten}${source.slice(lastIndex)}`;
|
|
689
696
|
}
|
|
690
697
|
|
|
698
|
+
const BARE_EXTENSION_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g;
|
|
699
|
+
|
|
700
|
+
function isBareExtensionDependencySpecifier(specifier: string): boolean {
|
|
701
|
+
if (
|
|
702
|
+
specifier.startsWith(".") ||
|
|
703
|
+
specifier.startsWith("/") ||
|
|
704
|
+
specifier.startsWith("#") ||
|
|
705
|
+
specifier.startsWith("node:") ||
|
|
706
|
+
specifier.startsWith("bun:") ||
|
|
707
|
+
/^[a-z][a-z0-9+.-]*:/i.test(specifier)
|
|
708
|
+
) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
const packageName = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
|
712
|
+
return Boolean(packageName && !isBuiltin(packageName));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
interface BarePackageSpecifier {
|
|
716
|
+
readonly name: string;
|
|
717
|
+
readonly subpath: string | null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function splitBarePackageSpecifier(specifier: string): BarePackageSpecifier | null {
|
|
721
|
+
const parts = specifier.split("/");
|
|
722
|
+
if (specifier.startsWith("@")) {
|
|
723
|
+
const [scope, name, ...rest] = parts;
|
|
724
|
+
if (!scope || !name) return null;
|
|
725
|
+
return { name: `${scope}/${name}`, subpath: rest.length > 0 ? rest.join("/") : null };
|
|
726
|
+
}
|
|
727
|
+
const [name, ...rest] = parts;
|
|
728
|
+
if (!name) return null;
|
|
729
|
+
return { name, subpath: rest.length > 0 ? rest.join("/") : null };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function findNodePackageRoot(packageName: string, importerPath: string): Promise<string | null> {
|
|
733
|
+
let dir = path.dirname(importerPath);
|
|
734
|
+
while (true) {
|
|
735
|
+
const candidate = path.join(dir, "node_modules", packageName);
|
|
736
|
+
if (await pathExists(path.join(candidate, "package.json"))) {
|
|
737
|
+
return candidate;
|
|
738
|
+
}
|
|
739
|
+
const parent = path.dirname(dir);
|
|
740
|
+
if (parent === dir) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
dir = parent;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
|
|
748
|
+
try {
|
|
749
|
+
const manifest = await Bun.file(path.join(packageRoot, "package.json")).json();
|
|
750
|
+
return isRecord(manifest) ? manifest : null;
|
|
751
|
+
} catch {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function resolvePackageExportTarget(
|
|
757
|
+
packageRoot: string,
|
|
758
|
+
target: string,
|
|
759
|
+
wildcard: string | null,
|
|
760
|
+
): Promise<string | null> {
|
|
761
|
+
if (!target.startsWith("./")) {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
|
|
765
|
+
return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function resolveNodePackageExport(
|
|
769
|
+
packageRoot: string,
|
|
770
|
+
subpath: string | null,
|
|
771
|
+
manifest: Record<string, unknown>,
|
|
772
|
+
): Promise<string | null> {
|
|
773
|
+
const exportsField = manifest.exports;
|
|
774
|
+
const rootTarget = subpath === null ? selectPackageImportTarget(exportsField) : null;
|
|
775
|
+
if (rootTarget !== null && rootTarget !== PACKAGE_IMPORT_EXCLUDED) {
|
|
776
|
+
return resolvePackageExportTarget(packageRoot, rootTarget, null);
|
|
777
|
+
}
|
|
778
|
+
if (!isRecord(exportsField)) {
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const exactKey = subpath === null ? "." : `./${subpath}`;
|
|
783
|
+
const exactTarget = selectPackageImportTarget(exportsField[exactKey]);
|
|
784
|
+
if (exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED) {
|
|
785
|
+
return resolvePackageExportTarget(packageRoot, exactTarget, null);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
for (const [key, entry] of Object.entries(exportsField)) {
|
|
789
|
+
const starIndex = key.indexOf("*");
|
|
790
|
+
if (starIndex === -1 || subpath === null) continue;
|
|
791
|
+
const prefix = key.slice(2, starIndex);
|
|
792
|
+
const suffix = key.slice(starIndex + 1);
|
|
793
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const target = selectPackageImportTarget(entry);
|
|
797
|
+
if (target === null || target === PACKAGE_IMPORT_EXCLUDED) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
return resolvePackageExportTarget(
|
|
801
|
+
packageRoot,
|
|
802
|
+
target,
|
|
803
|
+
subpath.slice(prefix.length, subpath.length - suffix.length),
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function resolveNodePackageFallback(
|
|
810
|
+
packageRoot: string,
|
|
811
|
+
subpath: string | null,
|
|
812
|
+
manifest: Record<string, unknown>,
|
|
813
|
+
): Promise<string | null> {
|
|
814
|
+
if (subpath !== null) {
|
|
815
|
+
return resolveSourceModuleFile(path.join(packageRoot, subpath));
|
|
816
|
+
}
|
|
817
|
+
for (const field of ["module", "main"]) {
|
|
818
|
+
const target = manifest[field];
|
|
819
|
+
if (typeof target === "string") {
|
|
820
|
+
const resolved = await resolveSourceModuleFile(path.resolve(packageRoot, target));
|
|
821
|
+
if (resolved) return resolved;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return resolveSourceModuleFile(path.join(packageRoot, "index"));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function resolveNodePackageDependency(specifier: string, importerPath: string): Promise<string | null> {
|
|
828
|
+
const parsed = splitBarePackageSpecifier(specifier);
|
|
829
|
+
if (!parsed) return null;
|
|
830
|
+
const packageRoot = await findNodePackageRoot(parsed.name, importerPath);
|
|
831
|
+
if (!packageRoot) return null;
|
|
832
|
+
const manifest = await readPackageManifest(packageRoot);
|
|
833
|
+
if (!manifest) return null;
|
|
834
|
+
return (
|
|
835
|
+
(await resolveNodePackageExport(packageRoot, parsed.subpath, manifest)) ??
|
|
836
|
+
(await resolveNodePackageFallback(packageRoot, parsed.subpath, manifest))
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function resolveExtensionBareDependency(specifier: string, importerPath: string): Promise<string | null> {
|
|
841
|
+
if (!isBareExtensionDependencySpecifier(specifier)) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
|
|
846
|
+
if (resolved && resolved !== specifier && !resolved.startsWith("node:") && !resolved.startsWith("bun:")) {
|
|
847
|
+
return resolved;
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
// Compiled binaries do not reliably resolve runtime extension node_modules.
|
|
851
|
+
}
|
|
852
|
+
return resolveNodePackageDependency(specifier, importerPath);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function rewriteExtensionBareImports(source: string, importerPath: string): Promise<string> {
|
|
856
|
+
let rewritten = "";
|
|
857
|
+
let lastIndex = 0;
|
|
858
|
+
for (const match of source.matchAll(BARE_EXTENSION_IMPORT_SPECIFIER_REGEX)) {
|
|
859
|
+
const matchIndex = match.index;
|
|
860
|
+
if (matchIndex === undefined) continue;
|
|
861
|
+
|
|
862
|
+
const [fullMatch, prefix, specifier, suffix] = match;
|
|
863
|
+
if (!prefix || !specifier || !suffix) continue;
|
|
864
|
+
|
|
865
|
+
const resolved = await resolveExtensionBareDependency(specifier, importerPath);
|
|
866
|
+
if (!resolved) continue;
|
|
867
|
+
|
|
868
|
+
rewritten += source.slice(lastIndex, matchIndex);
|
|
869
|
+
rewritten += `${prefix}${toImportSpecifier(resolved)}${suffix}`;
|
|
870
|
+
lastIndex = matchIndex + fullMatch.length;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (lastIndex === 0) {
|
|
874
|
+
return source;
|
|
875
|
+
}
|
|
876
|
+
return `${rewritten}${source.slice(lastIndex)}`;
|
|
877
|
+
}
|
|
878
|
+
|
|
691
879
|
function escapeRegExp(value: string): string {
|
|
692
880
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
693
881
|
}
|
package/src/mcp/manager.ts
CHANGED
|
@@ -1229,8 +1229,15 @@ export class MCPManager {
|
|
|
1229
1229
|
const tokenUrl = material?.tokenUrl;
|
|
1230
1230
|
const clientId = material?.clientId;
|
|
1231
1231
|
const clientSecret = material?.clientSecret;
|
|
1232
|
-
|
|
1233
|
-
|
|
1232
|
+
// `authorizationUrl` only lives on the embedded credential form;
|
|
1233
|
+
// legacy `MCPAuthConfig` rows never carried it. Required to filter
|
|
1234
|
+
// same-origin resource indicators on refresh when the authorize and
|
|
1235
|
+
// token endpoints sit on different origins (issue #3502 review
|
|
1236
|
+
// follow-up).
|
|
1237
|
+
const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
|
|
1238
|
+
const resourceIsFallback =
|
|
1239
|
+
!material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
|
|
1240
|
+
const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
|
|
1234
1241
|
// Proactive refresh: 5-minute buffer before expiry
|
|
1235
1242
|
// Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
|
|
1236
1243
|
const REFRESH_BUFFER_MS = 5 * 60_000;
|
|
@@ -1244,6 +1251,7 @@ export class MCPManager {
|
|
|
1244
1251
|
clientId,
|
|
1245
1252
|
clientSecret,
|
|
1246
1253
|
resource,
|
|
1254
|
+
{ authorizationUrl, stripSameOriginResource: resourceIsFallback },
|
|
1247
1255
|
);
|
|
1248
1256
|
// Spread the old credential first so embedded refresh material survives rotation.
|
|
1249
1257
|
const refreshedCredential: MCPStoredOAuthCredential = {
|
|
@@ -1252,7 +1260,8 @@ export class MCPManager {
|
|
|
1252
1260
|
tokenUrl,
|
|
1253
1261
|
clientId,
|
|
1254
1262
|
clientSecret,
|
|
1255
|
-
resource,
|
|
1263
|
+
resource: resourceIsFallback ? undefined : resource,
|
|
1264
|
+
authorizationUrl,
|
|
1256
1265
|
};
|
|
1257
1266
|
await this.#authStorage.set(credentialId, refreshedCredential);
|
|
1258
1267
|
credential = refreshedCredential;
|
|
@@ -250,6 +250,45 @@ export function analyzeAuthError(error: Error, serverUrl?: string): AuthDetectio
|
|
|
250
250
|
};
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Normalize an OAuth issuer URL for RFC 8414 §3.3 comparison: lowercase
|
|
255
|
+
* scheme/host (URL parser already does this), drop fragment/query, strip a
|
|
256
|
+
* trailing slash on the path. The path is otherwise case-sensitive.
|
|
257
|
+
*/
|
|
258
|
+
function normalizeIssuerUrl(value: string): string | undefined {
|
|
259
|
+
try {
|
|
260
|
+
const u = new URL(value);
|
|
261
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
262
|
+
return `${u.protocol}//${u.host}${path}`;
|
|
263
|
+
} catch {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* RFC 8414 §3.3: an authorization-server metadata document's `issuer` MUST
|
|
270
|
+
* equal the URL the client used to construct the metadata URL. When a server
|
|
271
|
+
* hosts metadata for several issuers under one origin (Plane serves a root
|
|
272
|
+
* issuer `https://mcp.plane.so/` at `/.well-known/oauth-authorization-server`
|
|
273
|
+
* *and* a path-scoped issuer `https://mcp.plane.so/http` at the path-prefixed
|
|
274
|
+
* well-known URL), accepting the first hit silently routes the grant to the
|
|
275
|
+
* wrong `/authorize` endpoint and produces opaque `server_error` redirects.
|
|
276
|
+
*
|
|
277
|
+
* Returns true when the metadata is safe to use:
|
|
278
|
+
* - the document has no `issuer` field (nonstandard / legacy servers — keep
|
|
279
|
+
* today's permissive behavior), or
|
|
280
|
+
* - the issuer matches `baseUrl` after trailing-slash normalization.
|
|
281
|
+
*/
|
|
282
|
+
function issuerMatchesBase(metadataIssuer: unknown, baseUrl: string): boolean {
|
|
283
|
+
if (typeof metadataIssuer !== "string" || !metadataIssuer.trim()) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
const normalizedIssuer = normalizeIssuerUrl(metadataIssuer);
|
|
287
|
+
const normalizedBase = normalizeIssuerUrl(baseUrl);
|
|
288
|
+
if (!normalizedIssuer || !normalizedBase) return true;
|
|
289
|
+
return normalizedIssuer === normalizedBase;
|
|
290
|
+
}
|
|
291
|
+
|
|
253
292
|
/**
|
|
254
293
|
* Try to discover OAuth endpoints by querying the server's well-known endpoints.
|
|
255
294
|
* This is a fallback when error responses don't include OAuth metadata.
|
|
@@ -389,7 +428,15 @@ export async function discoverOAuthEndpoints(
|
|
|
389
428
|
|
|
390
429
|
if (response.ok) {
|
|
391
430
|
const metadata = (await response.json()) as Record<string, unknown>;
|
|
392
|
-
|
|
431
|
+
// Authorization-server / OpenID Connect metadata documents carry an
|
|
432
|
+
// `issuer` field that MUST equal the queried base URL (RFC 8414 §3.3,
|
|
433
|
+
// OIDC Discovery §4.3). Protected-resource and nonstandard paths use a
|
|
434
|
+
// different schema, so the issuer check only gates the two official
|
|
435
|
+
// auth-server documents.
|
|
436
|
+
const requireIssuerMatch =
|
|
437
|
+
path === "/.well-known/oauth-authorization-server" || path === "/.well-known/openid-configuration";
|
|
438
|
+
const issuerOk = requireIssuerMatch ? issuerMatchesBase(metadata.issuer, baseUrl) : true;
|
|
439
|
+
const endpoints = issuerOk ? findEndpoints(metadata) : null;
|
|
393
440
|
if (endpoints) return endpoints;
|
|
394
441
|
|
|
395
442
|
if (path === "/.well-known/oauth-protected-resource") {
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -62,6 +62,14 @@ export interface MCPStoredOAuthCredential extends OAuthCredential {
|
|
|
62
62
|
clientId?: string;
|
|
63
63
|
clientSecret?: string;
|
|
64
64
|
resource?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Authorization-server URL (the issuer the grant was minted against). Used
|
|
67
|
+
* to filter same-origin resource indicators on refresh: RFC 8414 lets the
|
|
68
|
+
* authorize and token endpoints sit on different origins, so refresh
|
|
69
|
+
* cannot infer the original auth-server origin from `tokenUrl` alone.
|
|
70
|
+
* Unset on legacy credentials minted before issue #3502's fix.
|
|
71
|
+
*/
|
|
72
|
+
authorizationUrl?: string;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
const DEFAULT_PORT = 3000;
|
|
@@ -169,6 +177,42 @@ function resolveResourceUri(resource: string | undefined): string | undefined {
|
|
|
169
177
|
return trimmed;
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
interface ResourceIndicatorFilterOptions {
|
|
181
|
+
/** Strip any resource URL on the same origin as the authorization server. */
|
|
182
|
+
stripSameOriginResource?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Drop a redundant fallback resource indicator relative to {@link serverUrl}.
|
|
187
|
+
*
|
|
188
|
+
* Provider-advertised resource indicators are authoritative even when they are
|
|
189
|
+
* origin-only (`https://gateway.example.com`) or path-scoped same-origin
|
|
190
|
+
* (`https://gateway.example.com/my-service/mcp`): servers can use either form
|
|
191
|
+
* as the audience they require for the grant.
|
|
192
|
+
*
|
|
193
|
+
* Plane is stricter for OMP-synthesized fallback resources (e.g. using the
|
|
194
|
+
* configured server URL `https://mcp.plane.so/http/mcp` as `resource`), so
|
|
195
|
+
* fallback callers opt into `stripSameOriginResource`. Provider-advertised
|
|
196
|
+
* `oauth.resource` values and authorization-URL `?resource=` values keep the
|
|
197
|
+
* preserving default.
|
|
198
|
+
*/
|
|
199
|
+
function filterResourceIndicator(
|
|
200
|
+
resource: string | undefined,
|
|
201
|
+
serverUrl: string,
|
|
202
|
+
options: ResourceIndicatorFilterOptions = {},
|
|
203
|
+
): string | undefined {
|
|
204
|
+
if (!resource) return undefined;
|
|
205
|
+
try {
|
|
206
|
+
const origin = new URL(serverUrl).origin;
|
|
207
|
+
const parsedResource = new URL(resource);
|
|
208
|
+
if (parsedResource.origin !== origin) return resource;
|
|
209
|
+
if (options.stripSameOriginResource) return undefined;
|
|
210
|
+
} catch {
|
|
211
|
+
// Malformed serverUrl will fail elsewhere; fall through.
|
|
212
|
+
}
|
|
213
|
+
return resource;
|
|
214
|
+
}
|
|
215
|
+
|
|
172
216
|
export interface MCPOAuthConfig {
|
|
173
217
|
/** Authorization endpoint URL */
|
|
174
218
|
authorizationUrl: string;
|
|
@@ -197,6 +241,13 @@ export interface MCPOAuthConfig {
|
|
|
197
241
|
callbackPath?: string;
|
|
198
242
|
/** MCP resource URI for RFC 8707 resource indicators */
|
|
199
243
|
resource?: string;
|
|
244
|
+
/**
|
|
245
|
+
* True when `resource` was synthesized from the server URL fallback rather
|
|
246
|
+
* than advertised by OAuth/protected-resource metadata. Fallback resources
|
|
247
|
+
* are stripped when same-origin with the authorization server; advertised
|
|
248
|
+
* path-scoped resources are preserved.
|
|
249
|
+
*/
|
|
250
|
+
stripSameOriginResource?: boolean;
|
|
200
251
|
/** Fetch implementation for token exchange and discovery requests. */
|
|
201
252
|
fetch?: FetchImpl;
|
|
202
253
|
}
|
|
@@ -219,8 +270,8 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
219
270
|
super(ctrl, resolveCallbackOptions(config));
|
|
220
271
|
this.#resolvedClientId = this.#resolveClientId(config);
|
|
221
272
|
this.#fetch = config.fetch ?? ctrl.fetch ?? fetch;
|
|
222
|
-
this.#resource =
|
|
223
|
-
config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl),
|
|
273
|
+
this.#resource = this.#filterResourceIndicator(
|
|
274
|
+
resolveResourceUri(config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl)),
|
|
224
275
|
);
|
|
225
276
|
}
|
|
226
277
|
|
|
@@ -246,6 +297,15 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
246
297
|
get resource(): string | undefined {
|
|
247
298
|
return this.#resource;
|
|
248
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Authorization-server URL the flow used. Persist alongside the credential
|
|
302
|
+
* so refresh can filter same-origin resource indicators against the issuer's
|
|
303
|
+
* origin even when `tokenUrl` lives on a different origin (RFC 8414 permits
|
|
304
|
+
* the split).
|
|
305
|
+
*/
|
|
306
|
+
get authorizationUrl(): string {
|
|
307
|
+
return this.config.authorizationUrl;
|
|
308
|
+
}
|
|
249
309
|
|
|
250
310
|
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
251
311
|
if (!this.#resolvedClientId) {
|
|
@@ -271,7 +331,20 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
271
331
|
}
|
|
272
332
|
const existingResource = params.get("resource")?.trim();
|
|
273
333
|
if (existingResource) {
|
|
274
|
-
|
|
334
|
+
// A resource already embedded in the provider's authorization URL is
|
|
335
|
+
// provider-authored, not OMP's server-URL fallback. Preserve same-host
|
|
336
|
+
// values here even when the caller marked its separate
|
|
337
|
+
// `config.resource` as fallback; gateway-hosted MCP servers can use
|
|
338
|
+
// origin-only or path-scoped values as the token audience.
|
|
339
|
+
const filtered = filterResourceIndicator(resolveResourceUri(existingResource), this.config.authorizationUrl);
|
|
340
|
+
if (filtered) {
|
|
341
|
+
this.#resource = filtered;
|
|
342
|
+
} else {
|
|
343
|
+
// Defensive path for future policy additions: when filtering says
|
|
344
|
+
// "omit", drop it from both authorize and token requests.
|
|
345
|
+
params.delete("resource");
|
|
346
|
+
this.#resource = undefined;
|
|
347
|
+
}
|
|
275
348
|
} else if (this.#resource) {
|
|
276
349
|
params.set("resource", this.#resource);
|
|
277
350
|
}
|
|
@@ -395,6 +468,17 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
395
468
|
}
|
|
396
469
|
}
|
|
397
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Drop redundant resource indicators for this authorization server.
|
|
473
|
+
* Provider-advertised path-scoped values are preserved; fallback server-URL
|
|
474
|
+
* values opt into same-origin stripping via `stripSameOriginResource`.
|
|
475
|
+
*/
|
|
476
|
+
#filterResourceIndicator(resource: string | undefined): string | undefined {
|
|
477
|
+
return filterResourceIndicator(resource, this.config.authorizationUrl, {
|
|
478
|
+
stripSameOriginResource: this.config.stripSameOriginResource,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
398
482
|
/**
|
|
399
483
|
* Try OAuth dynamic client registration when provider requires a client_id.
|
|
400
484
|
*/
|
|
@@ -507,6 +591,26 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
507
591
|
}
|
|
508
592
|
}
|
|
509
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Options for {@link refreshMCPOAuthToken}. Carried via the trailing object
|
|
596
|
+
* so positional callers keep working.
|
|
597
|
+
*/
|
|
598
|
+
export interface RefreshMCPOAuthTokenOptions {
|
|
599
|
+
fetch?: FetchImpl;
|
|
600
|
+
/**
|
|
601
|
+
* Authorization-server URL the original grant was minted against. Used to
|
|
602
|
+
* filter same-origin resource indicators on refresh. Defaults to `tokenUrl`'s
|
|
603
|
+
* origin when omitted for legacy credentials.
|
|
604
|
+
*/
|
|
605
|
+
authorizationUrl?: string;
|
|
606
|
+
/**
|
|
607
|
+
* True when the refresh `resource` was synthesized from the server URL
|
|
608
|
+
* fallback because the credential/auth material carried no resource.
|
|
609
|
+
* Preserved advertised resources leave this false/undefined.
|
|
610
|
+
*/
|
|
611
|
+
stripSameOriginResource?: boolean;
|
|
612
|
+
}
|
|
613
|
+
|
|
510
614
|
/**
|
|
511
615
|
* Refresh an MCP OAuth token using the standard refresh_token grant.
|
|
512
616
|
* Returns updated credentials; preserves the old refresh token if the server doesn't rotate it.
|
|
@@ -516,17 +620,27 @@ export async function refreshMCPOAuthToken(
|
|
|
516
620
|
refreshToken: string,
|
|
517
621
|
clientId?: string,
|
|
518
622
|
clientSecret?: string,
|
|
519
|
-
resourceOrOpts?: string |
|
|
520
|
-
opts?:
|
|
623
|
+
resourceOrOpts?: string | RefreshMCPOAuthTokenOptions,
|
|
624
|
+
opts?: RefreshMCPOAuthTokenOptions,
|
|
521
625
|
): Promise<OAuthCredentials> {
|
|
522
|
-
const
|
|
626
|
+
const optsFromTrailing = typeof resourceOrOpts === "string" ? opts : resourceOrOpts;
|
|
627
|
+
const fetchImpl: FetchImpl = optsFromTrailing?.fetch ?? fetch;
|
|
523
628
|
const resource = typeof resourceOrOpts === "string" ? resourceOrOpts : undefined;
|
|
629
|
+
// Filter against the authorization-server origin when known (RFC 8414
|
|
630
|
+
// permits authorize/token endpoints on separate origins). Fall back to
|
|
631
|
+
// `tokenUrl` for legacy credentials minted before the issuer was persisted
|
|
632
|
+
// — same-origin servers (the common case) still match correctly.
|
|
633
|
+
const filterAnchor = optsFromTrailing?.authorizationUrl ?? tokenUrl;
|
|
524
634
|
const params = new URLSearchParams({
|
|
525
635
|
grant_type: "refresh_token",
|
|
526
636
|
refresh_token: refreshToken,
|
|
527
637
|
});
|
|
528
638
|
if (clientId) params.set("client_id", clientId);
|
|
529
|
-
|
|
639
|
+
// Drop redundant indicators so refresh stays consistent with the initial
|
|
640
|
+
// grant; see {@link filterResourceIndicator} for context.
|
|
641
|
+
const resolvedResource = filterResourceIndicator(resolveResourceUri(resource), filterAnchor, {
|
|
642
|
+
stripSameOriginResource: optsFromTrailing?.stripSameOriginResource,
|
|
643
|
+
});
|
|
530
644
|
if (resolvedResource) params.set("resource", resolvedResource);
|
|
531
645
|
if (clientSecret) params.set("client_secret", clientSecret);
|
|
532
646
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { resolveStdioSpawnCommand } from "./stdio";
|
|
4
|
+
|
|
5
|
+
describe("resolveStdioSpawnCommand", () => {
|
|
6
|
+
it("hides direct Windows executable MCP servers", async () => {
|
|
7
|
+
await expect(
|
|
8
|
+
resolveStdioSpawnCommand(
|
|
9
|
+
{ command: "server.exe", args: ["--stdio"] },
|
|
10
|
+
{ cwd: process.cwd(), env: {}, platform: "win32" },
|
|
11
|
+
),
|
|
12
|
+
).resolves.toEqual({
|
|
13
|
+
cmd: ["server.exe", "--stdio"],
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("keeps off-Windows spawn options unchanged", async () => {
|
|
19
|
+
await expect(
|
|
20
|
+
resolveStdioSpawnCommand(
|
|
21
|
+
{ command: "server.exe", args: ["--stdio"] },
|
|
22
|
+
{ cwd: process.cwd(), env: {}, platform: "linux" },
|
|
23
|
+
),
|
|
24
|
+
).resolves.toEqual({
|
|
25
|
+
cmd: ["server.exe", "--stdio"],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -239,12 +239,15 @@ export async function resolveStdioSpawnCommand(
|
|
|
239
239
|
// Direct-spawn only when we resolved to a concrete file AND its extension
|
|
240
240
|
// is not a batch script. Everything else (resolved .cmd/.bat, or an
|
|
241
241
|
// unresolved extensionless command) goes through cmd.exe so PATHEXT runs.
|
|
242
|
+
// Every Windows stdio server launch hides its console window; otherwise
|
|
243
|
+
// direct .exe servers pop a visible cmd window while the MCP server lives.
|
|
244
|
+
const windowsHide = true;
|
|
242
245
|
const needsCmdExe = resolved === null || isWindowsBatchCommand(resolvedCommand);
|
|
243
|
-
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args] };
|
|
246
|
+
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide };
|
|
244
247
|
|
|
245
248
|
return {
|
|
246
249
|
cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
|
|
247
|
-
windowsHide
|
|
250
|
+
windowsHide,
|
|
248
251
|
};
|
|
249
252
|
}
|
|
250
253
|
|
|
@@ -83,6 +83,7 @@ export class ChatTranscriptBuilder {
|
|
|
83
83
|
#pendingUsage: Usage | undefined;
|
|
84
84
|
#lastAssistantUsage: Usage | undefined;
|
|
85
85
|
#waitingPoll: ToolExecutionComponent | null = null;
|
|
86
|
+
#todoSnapshot: ToolExecutionComponent | null = null;
|
|
86
87
|
#expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
|
|
87
88
|
#expanded = false;
|
|
88
89
|
|
|
@@ -128,6 +129,7 @@ export class ChatTranscriptBuilder {
|
|
|
128
129
|
this.#pendingUsage = undefined;
|
|
129
130
|
this.#lastAssistantUsage = undefined;
|
|
130
131
|
this.#waitingPoll = null;
|
|
132
|
+
this.#todoSnapshot = null;
|
|
131
133
|
this.#expandables = [];
|
|
132
134
|
this.container.dispose();
|
|
133
135
|
this.container.clear();
|
|
@@ -153,6 +155,24 @@ export class ChatTranscriptBuilder {
|
|
|
153
155
|
previous.seal();
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
#resolveTodoSnapshot(nextToolName?: string): void {
|
|
159
|
+
const previous = this.#todoSnapshot;
|
|
160
|
+
if (!previous) return;
|
|
161
|
+
if (!previous.isDisplaceableBlock()) {
|
|
162
|
+
this.#todoSnapshot = null;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (previous.canBeDisplacedBy(nextToolName)) {
|
|
166
|
+
this.#todoSnapshot = null;
|
|
167
|
+
this.container.removeChild(previous);
|
|
168
|
+
previous.seal();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (nextToolName !== undefined) return;
|
|
172
|
+
this.#todoSnapshot = null;
|
|
173
|
+
previous.seal();
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
#ensureReadGroup(): ReadToolGroupComponent {
|
|
157
177
|
if (!this.#readGroup) {
|
|
158
178
|
this.#readGroup = new ReadToolGroupComponent({
|
|
@@ -189,6 +209,7 @@ export class ChatTranscriptBuilder {
|
|
|
189
209
|
case "developer": {
|
|
190
210
|
// A user prompt closes the poll-displacement window, same as the live path.
|
|
191
211
|
if (message.role === "user") this.#resolveWaitingPoll();
|
|
212
|
+
if (message.role === "user") this.#resolveTodoSnapshot();
|
|
192
213
|
const textContent = message.role === "user" ? userMessageText(message) : "";
|
|
193
214
|
if (textContent) {
|
|
194
215
|
const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
|
|
@@ -345,6 +366,16 @@ export class ChatTranscriptBuilder {
|
|
|
345
366
|
this.#pendingTools.delete(message.toolCallId);
|
|
346
367
|
if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
|
|
347
368
|
this.#waitingPoll = pending;
|
|
369
|
+
} else if (
|
|
370
|
+
message.toolName === "todo" &&
|
|
371
|
+
pending instanceof ToolExecutionComponent &&
|
|
372
|
+
pending.canBeDisplacedBy("todo")
|
|
373
|
+
) {
|
|
374
|
+
// A successful todo result supersedes the prior live snapshot. Failed
|
|
375
|
+
// follow-ups return false from canBeDisplacedBy("todo"), so the
|
|
376
|
+
// last-good panel stays on screen.
|
|
377
|
+
this.#resolveTodoSnapshot("todo");
|
|
378
|
+
this.#todoSnapshot = pending;
|
|
348
379
|
}
|
|
349
380
|
}
|
|
350
381
|
|