Merge pull request #2368 from modelcontextprotocol/v2/fix/2295-pathless-skill-uri

fix: accept a path-less skill root URI in normalizeSkillUri
This commit is contained in:
Cliff Hall 2026-09-14 01:18:19 -04:00 • committed by GitHub
commit 6ebb88c3a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 70 additions and 1 deletions

View file

@ -2184,6 +2184,31 @@ describe("SkillsScreen directory browsing (#2248)", () => {
).not.toBeInTheDocument();
});
it("names a skill root echoed back among its own children as not a direct child (#2295)", async () => {
// The root is path-less (`skill://data-analysis`), which
// `normalizeSkillUri` used to reject — so the containment check failed and
// the row read "outside this skill" while being the skill itself.
const user = userEvent.setup();
const ROOT_SELF = {
uri: ROOT,
name: "data-analysis",
mimeType: "inode/directory",
};
await openRoot(
user,
directoryReader({ [ROOT]: { resources: [CHILD_FILE, ROOT_SELF] } }),
);
const table = within(screen.getByTestId("skill-directory"));
expect(table.getByText(/not a direct child/)).toBeInTheDocument();
expect(table.queryByText(/outside this skill/)).not.toBeInTheDocument();
expect(
table.queryByRole("button", { name: `Open directory ${ROOT}` }),
).not.toBeInTheDocument();
expect(
table.getByRole("button", { name: `View ${CHILD_FILE.uri}` }),
).toBeInTheDocument();
});
it("navigates on the normalized URI, so Up cannot walk into a `..` segment", async () => {
// Containment was decided on the normalized URI while navigation sent and
// stored the raw one, so for `skill://root/a/../templates` the first Up

View file

@ -174,6 +174,36 @@ describe("normalizeSkillUri", () => {
// `skill:demo/../x.md` parses but keeps its `..` verbatim, so containment
// could not be decided on it — accepting it would reopen the hole.
expect(normalizeSkillUri("skill:demo/SKILL.md")).toBeUndefined();
// Path-less but still opaque: no authority, so it stays rejected.
expect(normalizeSkillUri("skill:demo")).toBeUndefined();
// Empty path AND no authority — the parser reports `pathname === ""` for
// these too, so an empty path alone must not be read as the root form.
expect(normalizeSkillUri("skill:")).toBeUndefined();
expect(normalizeSkillUri("mailto:")).toBeUndefined();
expect(normalizeSkillUri("skill://")).toBeUndefined();
});
it("accepts a path-less authority URI — a skill's root directory (#2295)", () => {
// RFC 3986 gives an authority URI an empty path, so `skill://data-analysis`
// is well-formed; rejecting it mislabelled a root echoed back by a
// directory read as "outside this skill".
expect(normalizeSkillUri("skill://data-analysis")).toBe(
"skill://data-analysis",
);
expect(normalizeSkillUri("skill://%64ata-analysis")).toBe(
"skill://data-analysis",
);
});
it("still treats a path-less root as naming no skill file", () => {
// Accepting the root must not let it pass a check that needs a file.
expect(skillNameFromUri("skill://data-analysis")).toBeUndefined();
const issues = checkSkillConformance({
uri: "skill://data-analysis",
frontmatter: { name: "data-analysis", description: "d" },
resources: [],
});
expect(issues.map((issue) => issue.code)).toContain("malformed-uri");
});
it("decodes escapes that stand for unreserved characters", () => {

View file

@ -171,6 +171,17 @@ export function isSkillsExtensionSupported(
* rejected too: containment could not be decided on it, and silently accepting
* one would reintroduce exactly the hole this function closes.
*
* A **path-less authority** URI (`skill://data-analysis`) is accepted, and is
* not the opaque case above: RFC 3986 gives a URI with an authority an empty
* path, and the parser reports that as `pathname === ""`. The authority has to
* be non-empty — `skill:` parses to an empty path too, with no host. It has no segments,
* so nothing survives unnormalized. It is what a skill's root directory looks
* like once `/SKILL.md` is removed, so rejecting it made a server echoing that
* root back in a directory read read as "outside this skill" when it is the
* skill itself (#2295). It names no file, so it still fails every check that
* needs one — `skillNameFromUri` wants the `/SKILL.md` suffix, and a manifest
* entry spelled this way does not start with `<root>/`.
*
* The scheme is deliberately **not** constrained. `skill://` is what SEP-2640
* recommends and what this repo's fixture serves, but the SEP only says servers
* SHOULD use it and explicitly allows a domain-native scheme (`github://…`), so
@ -186,7 +197,10 @@ export function normalizeSkillUri(uri: string): string | undefined {
} catch {
return undefined;
}
if (!parsed.pathname.startsWith("/")) return undefined;
// An empty path is only the root form when an authority carries the identity:
// `skill:` and `mailto:` also parse to `pathname === ""`, with no host.
const pathlessRoot = parsed.pathname === "" && parsed.host !== "";
if (!pathlessRoot && !parsed.pathname.startsWith("/")) return undefined;
return canonicalizePercentEncoding(parsed.href);
}