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:
commit
6ebb88c3a8
3 changed files with 70 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue