diff --git a/server/routes/mcp/index.ts b/server/routes/mcp/index.ts index df5434e8fc..4c99f414c5 100644 --- a/server/routes/mcp/index.ts +++ b/server/routes/mcp/index.ts @@ -23,15 +23,21 @@ import { version } from "../../../package.json"; const app = new Koa(); const router = new Router(); +const defaultInstructions = `Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the list_users tool to find user IDs.`; + /** * Creates a fresh MCP server instance with tools filtered by the OAuth * scopes granted to the current token. * * @param scopes - the OAuth scopes granted to the access token. - * @param instructions - optional workspace guidance to send to clients. + * @param guidance - optional workspace guidance to append to default instructions. * @returns a configured McpServer ready to be connected to a transport. */ -function createMcpServer(scopes: string[], instructions?: string): McpServer { +function createMcpServer(scopes: string[], guidance?: string): McpServer { + const instructions = guidance + ? `${defaultInstructions}\n\n${guidance}` + : defaultInstructions; + const server = new McpServer( { name: "outline", @@ -41,7 +47,7 @@ function createMcpServer(scopes: string[], instructions?: string): McpServer { capabilities: { tools: {}, }, - ...(instructions ? { instructions } : {}), + instructions, } ); diff --git a/server/validation.ts b/server/validation.ts index 93166b50ab..ff8e18a2b9 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -251,9 +251,10 @@ export class ValidateURL { const { id, mentionType, modelId } = parseMentionUrl(url); return ( - id && - isUUID(id) && + (!id || isUUID(id)) && + !!mentionType && Object.values(MentionType).includes(mentionType as MentionType) && + !!modelId && isUUID(modelId) ); } catch (_err) { diff --git a/shared/editor/rules/mention.test.ts b/shared/editor/rules/mention.test.ts new file mode 100644 index 0000000000..5806668b68 --- /dev/null +++ b/shared/editor/rules/mention.test.ts @@ -0,0 +1,155 @@ +import markdownit from "markdown-it"; +import mentionRule from "./mention"; + +function findMentionTokens(tokens: ReturnType) { + const mentions: Array<{ + id: string | null; + type: string | null; + modelId: string | null; + label: string; + }> = []; + + for (const tok of tokens) { + if (tok.type === "inline" && tok.children) { + for (const child of tok.children) { + if (child.type === "mention") { + mentions.push({ + id: child.attrGet("id"), + type: child.attrGet("type"), + modelId: child.attrGet("modelId"), + label: child.content, + }); + } + } + } + } + + return mentions; +} + +describe("mention rule", () => { + const md = markdownit().use(mentionRule); + + describe("3-segment format (existing)", () => { + it("should parse a user mention", () => { + const result = md.parse( + "@[John Doe](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/user/f0e1d2c3-b4a5-6789-0abc-def123456789)", + {} + ); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(1); + expect(mentions[0]).toEqual({ + id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + type: "user", + modelId: "f0e1d2c3-b4a5-6789-0abc-def123456789", + label: "John Doe", + }); + }); + + it("should parse a group mention", () => { + const result = md.parse( + "@[Engineering](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/group/f0e1d2c3-b4a5-6789-0abc-def123456789)", + {} + ); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(1); + expect(mentions[0].type).toBe("group"); + expect(mentions[0].label).toBe("Engineering"); + }); + }); + + describe("2-segment format (new)", () => { + it("should parse a user mention", () => { + const result = md.parse( + "@[John Doe](mention://user/f0e1d2c3-b4a5-6789-0abc-def123456789)", + {} + ); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(1); + expect(mentions[0].type).toBe("user"); + expect(mentions[0].modelId).toBe("f0e1d2c3-b4a5-6789-0abc-def123456789"); + expect(mentions[0].label).toBe("John Doe"); + // instanceId should be auto-generated + expect(mentions[0].id).toBeTruthy(); + expect(mentions[0].id).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ + ); + }); + + it("should parse a group mention", () => { + const result = md.parse("@[Engineering](mention://group/abc123)", {}); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(1); + expect(mentions[0].type).toBe("group"); + expect(mentions[0].modelId).toBe("abc123"); + expect(mentions[0].label).toBe("Engineering"); + }); + + it("should parse mention with single-word name", () => { + const result = md.parse("@[Alice](mention://user/abc123)", {}); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(1); + expect(mentions[0].label).toBe("Alice"); + }); + }); + + describe("mixed content", () => { + it("should parse mention within text", () => { + const result = md.parse( + "Hello @[John Doe](mention://user/abc123) please review", + {} + ); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(1); + expect(mentions[0].label).toBe("John Doe"); + }); + + it("should parse multiple mentions", () => { + const result = md.parse( + "@[Alice](mention://user/id1) and @[Bob](mention://user/id2)", + {} + ); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(2); + expect(mentions[0].label).toBe("Alice"); + expect(mentions[1].label).toBe("Bob"); + }); + + it("should parse mix of 2-segment and 3-segment mentions", () => { + const result = md.parse( + "@[Alice](mention://user/id1) and @[Bob](mention://inst-id/user/id2)", + {} + ); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(2); + expect(mentions[0].label).toBe("Alice"); + expect(mentions[0].id).toBeTruthy(); // auto-generated + expect(mentions[1].label).toBe("Bob"); + expect(mentions[1].id).toBe("inst-id"); + }); + }); + + describe("non-mentions", () => { + it("should not parse regular links as mentions", () => { + const result = md.parse("[John Doe](https://example.com)", {}); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(0); + }); + + it("should not parse links without @ prefix as mentions", () => { + const result = md.parse("[John Doe](mention://user/abc123)", {}); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(0); + }); + }); +}); diff --git a/shared/editor/rules/mention.ts b/shared/editor/rules/mention.ts index 8c5f15fa31..0f5ef0834f 100644 --- a/shared/editor/rules/mention.ts +++ b/shared/editor/rules/mention.ts @@ -1,5 +1,40 @@ import type { Token, StateCore } from "markdown-it"; import type MarkdownIt from "markdown-it"; +import { v4 as uuidv4 } from "uuid"; +import parseMentionUrl from "@shared/utils/parseMentionUrl"; + +/** + * Check whether a URL is a valid mention:// href. + * + * @param href the URL string to test. + * @returns true when the href is a recognised mention URL. + */ +function isMentionHref(href: string) { + const { mentionType, modelId } = parseMentionUrl(href); + return mentionType !== undefined && modelId !== undefined; +} + +/** + * Parse a mention:// href into the id, type and modelId needed by the editor. + * For 2-segment URLs (no instance id) a fresh UUID is generated. + * + * @param href the mention URL to parse. + * @returns the parsed components. + * @throws when the href is not a valid mention URL. + */ +function parseMentionHref(href: string): { + id: string; + type: string; + modelId: string; +} { + const { id, mentionType, modelId } = parseMentionUrl(href); + + if (!mentionType || !modelId) { + throw new Error(`Invalid mention href: ${href}`); + } + + return { id: id ?? uuidv4(), type: mentionType, modelId }; +} function renderMention(tokens: Token[], idx: number) { const id = tokens[idx].attrGet("id"); @@ -11,8 +46,6 @@ function renderMention(tokens: Token[], idx: number) { } function parseMentions(state: StateCore) { - const hrefRE = /^mention:\/\/([a-z0-9-]+)\/([a-z]+)\/([a-z0-9-]+)$/; - for (let i = 0; i < state.tokens.length; i++) { const tok = state.tokens[i]; if (!(tok.type === "inline" && tok.children)) { @@ -43,7 +76,7 @@ function parseMentions(state: StateCore) { // "link_open" token should have valid href const attr = openToken.attrs?.[0]; - if (!(attr && attr[0] === "href" && hrefRE.test(attr[1]))) { + if (!(attr && attr[0] === "href" && isMentionHref(attr[1]))) { return false; } @@ -57,11 +90,10 @@ function parseMentions(state: StateCore) { // remove "@" from preceding token precToken.content = precToken.content.slice(0, -1); - // href must be present, otherwise the hrefRE test in canChunkComposeMentionToken would've failed + // href must be present, otherwise the isMentionHref test would've failed // oxlint-disable-next-line @typescript-eslint/no-non-null-assertion const href = openToken.attrs![0][1]; - const matches = href.match(hrefRE); - const [id, mType, mId] = matches!.slice(1); + const { id, type: mType, modelId: mId } = parseMentionHref(href); const mentionToken = new state.Token("mention", "", 0); mentionToken.attrSet("id", id); diff --git a/shared/utils/parseMentionUrl.test.ts b/shared/utils/parseMentionUrl.test.ts new file mode 100644 index 0000000000..0e4d896f73 --- /dev/null +++ b/shared/utils/parseMentionUrl.test.ts @@ -0,0 +1,58 @@ +import parseMentionUrl from "./parseMentionUrl"; + +describe("parseMentionUrl", () => { + it("should parse 3-segment mention URL", () => { + expect( + parseMentionUrl( + "mention://9a17c1c8-d178-4350-9001-203a73070fcb/user/abc123def456" + ) + ).toEqual({ + id: "9a17c1c8-d178-4350-9001-203a73070fcb", + mentionType: "user", + modelId: "abc123def456", + }); + }); + + it("should parse 2-segment mention URL", () => { + expect(parseMentionUrl("mention://user/abc123def456")).toEqual({ + mentionType: "user", + modelId: "abc123def456", + }); + }); + + it("should parse 2-segment mention URL with UUID modelId", () => { + expect( + parseMentionUrl("mention://user/9a17c1c8-d178-4350-9001-203a73070fcb") + ).toEqual({ + mentionType: "user", + modelId: "9a17c1c8-d178-4350-9001-203a73070fcb", + }); + }); + + it("should parse group mention type", () => { + expect(parseMentionUrl("mention://group/abc123")).toEqual({ + mentionType: "group", + modelId: "abc123", + }); + }); + + it("should parse pull_request mention type with underscore", () => { + expect( + parseMentionUrl( + "mention://9a17c1c8-d178-4350-9001-203a73070fcb/pull_request/abc123" + ) + ).toEqual({ + id: "9a17c1c8-d178-4350-9001-203a73070fcb", + mentionType: "pull_request", + modelId: "abc123", + }); + }); + + it("should return empty object for invalid URL", () => { + expect(parseMentionUrl("https://example.com")).toEqual({}); + }); + + it("should return empty object for empty string", () => { + expect(parseMentionUrl("")).toEqual({}); + }); +}); diff --git a/shared/utils/parseMentionUrl.ts b/shared/utils/parseMentionUrl.ts index f4ab806074..f746f93dc4 100644 --- a/shared/utils/parseMentionUrl.ts +++ b/shared/utils/parseMentionUrl.ts @@ -1,12 +1,30 @@ -const parseMentionUrl = (url: string) => { - const matches = url.match( - /^mention:\/\/([a-z0-9-]+)\/([a-z]+)\/([a-z0-9-]+)$/ +/** + * Parse a mention:// URL into its components. + * + * Supports both the 3-segment format (mention://id/type/modelId) and the + * 2-segment format (mention://type/modelId). + * + * @param url the mention URL to parse. + * @returns the parsed components, or an empty object if the URL is invalid. + */ +const parseMentionUrl = ( + url: string +): { id?: string; mentionType?: string; modelId?: string } => { + const match3 = url.match( + /^mention:\/\/([a-z0-9-]+)\/([a-z_]+)\/([a-z0-9-]+)$/ ); - if (!matches) { - return {}; + if (match3) { + const [id, mentionType, modelId] = match3.slice(1); + return { id, mentionType, modelId }; } - const [id, mentionType, modelId] = matches.slice(1); - return { id, mentionType, modelId }; + + const match2 = url.match(/^mention:\/\/([a-z_]+)\/([a-z0-9-]+)$/); + if (match2) { + const [mentionType, modelId] = match2.slice(1); + return { mentionType, modelId }; + } + + return {}; }; export default parseMentionUrl;