feat: Support simplified mention syntax in markdown for MCP (#11851)

* feat: Support simplified mention syntax in markdown for MCP clients

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Restore translations

* PR feedback

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-03-23 08:08:24 -04:00
committed by GitHub
parent 0e978e1e34
commit 7dc1d12d3b
6 changed files with 288 additions and 18 deletions
+9 -3
View File
@@ -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,
}
);
+3 -2
View File
@@ -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) {
+155
View File
@@ -0,0 +1,155 @@
import markdownit from "markdown-it";
import mentionRule from "./mention";
function findMentionTokens(tokens: ReturnType<markdownit["parse"]>) {
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);
});
});
});
+38 -6
View File
@@ -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);
+58
View File
@@ -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({});
});
});
+25 -7
View File
@@ -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;