mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user