mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Add Datadog tracing to MCP tool handlers (#11509)
Wraps all MCP tool and resource handlers with Datadog APM spans so that each invocation is visible in traces under the `outline-mcp` service. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+53
-49
@@ -16,6 +16,7 @@ import {
|
||||
getActorFromContext,
|
||||
buildAPIContext,
|
||||
pathToUrl,
|
||||
withTracing,
|
||||
} from "./util";
|
||||
|
||||
/**
|
||||
@@ -61,56 +62,59 @@ export function collectionTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ query, offset, limit }, extra) => {
|
||||
try {
|
||||
const user = getActorFromContext(extra);
|
||||
const collectionIds = await user.collectionIds();
|
||||
withTracing(
|
||||
"list_collections",
|
||||
async ({ query, offset, limit }, extra) => {
|
||||
try {
|
||||
const user = getActorFromContext(extra);
|
||||
const collectionIds = await user.collectionIds();
|
||||
|
||||
const and: WhereOptions<Collection>[] = [
|
||||
{ deletedAt: { [Op.eq]: null } },
|
||||
{ archivedAt: { [Op.eq]: null } },
|
||||
{ id: collectionIds },
|
||||
];
|
||||
const and: WhereOptions<Collection>[] = [
|
||||
{ deletedAt: { [Op.eq]: null } },
|
||||
{ archivedAt: { [Op.eq]: null } },
|
||||
{ id: collectionIds },
|
||||
];
|
||||
|
||||
if (query) {
|
||||
and.push(
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
|
||||
) as unknown as WhereOptions<Collection>
|
||||
);
|
||||
}
|
||||
if (query) {
|
||||
and.push(
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
|
||||
) as unknown as WhereOptions<Collection>
|
||||
);
|
||||
}
|
||||
|
||||
const where: WhereOptions<Collection> = {
|
||||
teamId: user.teamId,
|
||||
[Op.and]: and,
|
||||
};
|
||||
const where: WhereOptions<Collection> = {
|
||||
teamId: user.teamId,
|
||||
[Op.and]: and,
|
||||
};
|
||||
|
||||
const collections = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findAll({
|
||||
where,
|
||||
replacements: { query: `%${query}%` },
|
||||
order: [
|
||||
Sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
offset: offset ?? 0,
|
||||
limit: limit ?? 25,
|
||||
});
|
||||
const collections = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findAll({
|
||||
where,
|
||||
replacements: { query: `%${query}%` },
|
||||
order: [
|
||||
Sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
offset: offset ?? 0,
|
||||
limit: limit ?? 25,
|
||||
});
|
||||
|
||||
const presented = await Promise.all(
|
||||
collections.map(async (collection) =>
|
||||
pathToUrl(
|
||||
user.team,
|
||||
await presentCollection(undefined, collection)
|
||||
const presented = await Promise.all(
|
||||
collections.map(async (collection) =>
|
||||
pathToUrl(
|
||||
user.team,
|
||||
await presentCollection(undefined, collection)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
return success(presented);
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
);
|
||||
return success(presented);
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +128,7 @@ export function collectionTools(server: McpServer, scopes: string[]) {
|
||||
"Fetches the details of a collection by its ID, including its document structure.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
async (uri, variables, extra) => {
|
||||
withTracing("get_collection", async (uri, variables, extra) => {
|
||||
try {
|
||||
const { id } = variables;
|
||||
const user = getActorFromContext(extra);
|
||||
@@ -156,7 +160,7 @@ export function collectionTools(server: McpServer, scopes: string[]) {
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,7 +191,7 @@ export function collectionTools(server: McpServer, scopes: string[]) {
|
||||
.describe("The hex color for the collection icon, e.g. #FF0000."),
|
||||
},
|
||||
},
|
||||
async (input, context) => {
|
||||
withTracing("create_collection", async (input, context) => {
|
||||
try {
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -221,7 +225,7 @@ export function collectionTools(server: McpServer, scopes: string[]) {
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,7 +268,7 @@ export function collectionTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input, context) => {
|
||||
withTracing("update_collection", async (input, context) => {
|
||||
try {
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -298,7 +302,7 @@ export function collectionTools(server: McpServer, scopes: string[]) {
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+128
-116
@@ -9,7 +9,13 @@ import { Comment, Collection, Document } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentComment } from "@server/presenters";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
import { error, success, buildAPIContext, getActorFromContext } from "./util";
|
||||
import {
|
||||
error,
|
||||
success,
|
||||
buildAPIContext,
|
||||
getActorFromContext,
|
||||
withTracing,
|
||||
} from "./util";
|
||||
|
||||
/**
|
||||
* Presents a comment with a plain-text rendering of its content so that
|
||||
@@ -82,104 +88,107 @@ export function commentTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async (
|
||||
{
|
||||
documentId,
|
||||
collectionId,
|
||||
parentCommentId,
|
||||
statusFilter,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
extra
|
||||
) => {
|
||||
try {
|
||||
const user = getActorFromContext(extra);
|
||||
const effectiveOffset = offset ?? 0;
|
||||
const effectiveLimit = limit ?? 25;
|
||||
withTracing(
|
||||
"list_comments",
|
||||
async (
|
||||
{
|
||||
documentId,
|
||||
collectionId,
|
||||
parentCommentId,
|
||||
statusFilter,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
extra
|
||||
) => {
|
||||
try {
|
||||
const user = getActorFromContext(extra);
|
||||
const effectiveOffset = offset ?? 0;
|
||||
const effectiveLimit = limit ?? 25;
|
||||
|
||||
const statusQuery: WhereOptions<Comment>[] = [];
|
||||
if (statusFilter?.includes(CommentStatusFilter.Resolved)) {
|
||||
statusQuery.push({ resolvedById: { [Op.not]: null } });
|
||||
}
|
||||
if (statusFilter?.includes(CommentStatusFilter.Unresolved)) {
|
||||
statusQuery.push({ resolvedById: null });
|
||||
}
|
||||
const statusQuery: WhereOptions<Comment>[] = [];
|
||||
if (statusFilter?.includes(CommentStatusFilter.Resolved)) {
|
||||
statusQuery.push({ resolvedById: { [Op.not]: null } });
|
||||
}
|
||||
if (statusFilter?.includes(CommentStatusFilter.Unresolved)) {
|
||||
statusQuery.push({ resolvedById: null });
|
||||
}
|
||||
|
||||
const and: WhereOptions<Comment>[] = [];
|
||||
if (documentId) {
|
||||
and.push({ documentId });
|
||||
}
|
||||
if (parentCommentId) {
|
||||
and.push({ parentCommentId });
|
||||
}
|
||||
if (statusQuery.length) {
|
||||
and.push({ [Op.or]: statusQuery });
|
||||
}
|
||||
const where: WhereOptions<Comment> = {
|
||||
[Op.and]: and,
|
||||
};
|
||||
const and: WhereOptions<Comment>[] = [];
|
||||
if (documentId) {
|
||||
and.push({ documentId });
|
||||
}
|
||||
if (parentCommentId) {
|
||||
and.push({ parentCommentId });
|
||||
}
|
||||
if (statusQuery.length) {
|
||||
and.push({ [Op.or]: statusQuery });
|
||||
}
|
||||
const where: WhereOptions<Comment> = {
|
||||
[Op.and]: and,
|
||||
};
|
||||
|
||||
const params: FindOptions<Comment> = {
|
||||
where,
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
};
|
||||
const params: FindOptions<Comment> = {
|
||||
where,
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
};
|
||||
|
||||
let comments: Comment[];
|
||||
let comments: Comment[];
|
||||
|
||||
if (documentId) {
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
if (documentId) {
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
comments = await Comment.findAll(params);
|
||||
comments.forEach((comment) => (comment.document = document!));
|
||||
} else if (collectionId) {
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", collection);
|
||||
comments = await Comment.findAll(params);
|
||||
comments.forEach((comment) => (comment.document = document!));
|
||||
} else if (collectionId) {
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", collection);
|
||||
|
||||
comments = await Comment.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
required: true,
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
comments = await Comment.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
required: true,
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
const accessibleCollectionIds = await user.collectionIds();
|
||||
],
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
const accessibleCollectionIds = await user.collectionIds();
|
||||
|
||||
comments = await Comment.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
required: true,
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId: { [Op.in]: accessibleCollectionIds },
|
||||
comments = await Comment.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
required: true,
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId: { [Op.in]: accessibleCollectionIds },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
...params,
|
||||
});
|
||||
}
|
||||
],
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
const presented = comments.map(presentCommentWithText);
|
||||
return success(presented);
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
const presented = comments.map(presentCommentWithText);
|
||||
return success(presented);
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,37 +216,40 @@ export function commentTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ documentId, text, parentCommentId }, context) => {
|
||||
try {
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
withTracing(
|
||||
"create_comment",
|
||||
async ({ documentId, text, parentCommentId }, context) => {
|
||||
try {
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "comment", document);
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "comment", document);
|
||||
|
||||
const data = commentParser.parse(text).toJSON();
|
||||
const data = commentParser.parse(text).toJSON();
|
||||
|
||||
const comment = await Comment.createWithCtx(ctx, {
|
||||
data,
|
||||
createdById: user.id,
|
||||
documentId,
|
||||
parentCommentId,
|
||||
});
|
||||
const comment = await Comment.createWithCtx(ctx, {
|
||||
data,
|
||||
createdById: user.id,
|
||||
documentId,
|
||||
parentCommentId,
|
||||
});
|
||||
|
||||
comment.createdBy = user;
|
||||
comment.createdBy = user;
|
||||
|
||||
const presented = presentCommentWithText(comment);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(presented) },
|
||||
],
|
||||
} satisfies CallToolResult;
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
const presented = presentCommentWithText(comment);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(presented) },
|
||||
],
|
||||
} satisfies CallToolResult;
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,7 +280,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ id, text, status }, context) => {
|
||||
withTracing("update_comment", async ({ id, text, status }, context) => {
|
||||
try {
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -309,7 +321,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -330,7 +342,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
|
||||
.describe("The unique identifier of the comment to delete."),
|
||||
},
|
||||
},
|
||||
async ({ id }, context) => {
|
||||
withTracing("delete_comment", async ({ id }, context) => {
|
||||
try {
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -351,7 +363,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+63
-59
@@ -22,6 +22,7 @@ import {
|
||||
buildAPIContext,
|
||||
getActorFromContext,
|
||||
pathToUrl,
|
||||
withTracing,
|
||||
} from "./util";
|
||||
import { TextEditMode } from "@shared/types";
|
||||
|
||||
@@ -42,7 +43,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
description: "Fetches the content of a document by its ID.",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
async (uri, variables, extra) => {
|
||||
withTracing("get_document", async (uri, variables, extra) => {
|
||||
try {
|
||||
const { id } = variables;
|
||||
const user = getActorFromContext(extra);
|
||||
@@ -82,7 +83,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,74 +126,77 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ query, collectionId, offset, limit }, extra) => {
|
||||
try {
|
||||
const user = getActorFromContext(extra);
|
||||
const effectiveOffset = offset ?? 0;
|
||||
const effectiveLimit = limit ?? 25;
|
||||
withTracing(
|
||||
"list_documents",
|
||||
async ({ query, collectionId, offset, limit }, extra) => {
|
||||
try {
|
||||
const user = getActorFromContext(extra);
|
||||
const effectiveOffset = offset ?? 0;
|
||||
const effectiveLimit = limit ?? 25;
|
||||
|
||||
if (collectionId) {
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "readDocument", collection);
|
||||
}
|
||||
if (collectionId) {
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "readDocument", collection);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const { results } = await SearchHelper.searchForUser(user, {
|
||||
query,
|
||||
collectionId,
|
||||
if (query) {
|
||||
const { results } = await SearchHelper.searchForUser(user, {
|
||||
query,
|
||||
collectionId,
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
const presented = await Promise.all(
|
||||
results.map(async (result) => {
|
||||
const doc = pathToUrl(
|
||||
user.team,
|
||||
await presentDocument(undefined, result.document, {
|
||||
includeData: false,
|
||||
includeText: false,
|
||||
})
|
||||
);
|
||||
return { ...doc, context: result.context };
|
||||
})
|
||||
);
|
||||
return success(presented);
|
||||
}
|
||||
|
||||
const collectionIds = collectionId
|
||||
? [collectionId]
|
||||
: await user.collectionIds();
|
||||
|
||||
const documents = await Document.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId: collectionIds,
|
||||
archivedAt: { [Op.eq]: null },
|
||||
deletedAt: { [Op.eq]: null },
|
||||
},
|
||||
order: [["updatedAt", "DESC"]],
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
const presented = await Promise.all(
|
||||
results.map(async (result) => {
|
||||
const doc = pathToUrl(
|
||||
documents.map(async (document) =>
|
||||
pathToUrl(
|
||||
user.team,
|
||||
await presentDocument(undefined, result.document, {
|
||||
await presentDocument(undefined, document, {
|
||||
includeData: false,
|
||||
includeText: false,
|
||||
})
|
||||
);
|
||||
return { ...doc, context: result.context };
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
return success(presented);
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
|
||||
const collectionIds = collectionId
|
||||
? [collectionId]
|
||||
: await user.collectionIds();
|
||||
|
||||
const documents = await Document.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId: collectionIds,
|
||||
archivedAt: { [Op.eq]: null },
|
||||
deletedAt: { [Op.eq]: null },
|
||||
},
|
||||
order: [["updatedAt", "DESC"]],
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
const presented = await Promise.all(
|
||||
documents.map(async (document) =>
|
||||
pathToUrl(
|
||||
user.team,
|
||||
await presentDocument(undefined, document, {
|
||||
includeData: false,
|
||||
includeText: false,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
return success(presented);
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,7 +241,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input, context) => {
|
||||
withTracing("create_document", async (input, context) => {
|
||||
try {
|
||||
const { collectionId, parentDocumentId } = input;
|
||||
const ctx = buildAPIContext(context);
|
||||
@@ -301,7 +305,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -360,7 +364,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input, context) => {
|
||||
withTracing("update_document", async (input, context) => {
|
||||
try {
|
||||
const ctx = buildAPIContext(context);
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -412,7 +416,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
|
||||
} catch (message) {
|
||||
return error(message);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+89
-86
@@ -11,7 +11,7 @@ import { User, Team } from "@server/models";
|
||||
import { authorize, can } from "@server/policies";
|
||||
import { presentUser } from "@server/presenters";
|
||||
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
|
||||
import { error, success, getActorFromContext } from "./util";
|
||||
import { error, success, getActorFromContext, withTracing } from "./util";
|
||||
|
||||
/**
|
||||
* Resolves a user identifier to a User model instance. Accepts special
|
||||
@@ -50,7 +50,7 @@ export function userTools(server: McpServer, scopes: string[]) {
|
||||
'Fetches a user by their ID. Use "current_user" as the ID to get the currently authenticated user.',
|
||||
mimeType: "application/json",
|
||||
},
|
||||
async (uri, variables, extra) => {
|
||||
withTracing("get_user", async (uri, variables, extra) => {
|
||||
try {
|
||||
const { id } = variables;
|
||||
const actor = getActorFromContext(extra);
|
||||
@@ -78,7 +78,7 @@ export function userTools(server: McpServer, scopes: string[]) {
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,105 +131,108 @@ export function userTools(server: McpServer, scopes: string[]) {
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ query, role, filter, offset, limit }, extra) => {
|
||||
try {
|
||||
const actor = getActorFromContext(extra);
|
||||
const team = await Team.findByPk(actor.teamId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
authorize(actor, "listUsers", team);
|
||||
withTracing(
|
||||
"list_users",
|
||||
async ({ query, role, filter, offset, limit }, extra) => {
|
||||
try {
|
||||
const actor = getActorFromContext(extra);
|
||||
const team = await Team.findByPk(actor.teamId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
authorize(actor, "listUsers", team);
|
||||
|
||||
const effectiveOffset = offset ?? 0;
|
||||
const effectiveLimit = limit ?? 25;
|
||||
const effectiveOffset = offset ?? 0;
|
||||
const effectiveLimit = limit ?? 25;
|
||||
|
||||
let where: WhereOptions<User> = {
|
||||
teamId: actor.teamId,
|
||||
};
|
||||
|
||||
// Non-admins cannot see suspended users
|
||||
if (!actor.isAdmin) {
|
||||
where = {
|
||||
...where,
|
||||
suspendedAt: { [Op.eq]: null },
|
||||
let where: WhereOptions<User> = {
|
||||
teamId: actor.teamId,
|
||||
};
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case "invited": {
|
||||
where = { ...where, lastActiveAt: null };
|
||||
break;
|
||||
// Non-admins cannot see suspended users
|
||||
if (!actor.isAdmin) {
|
||||
where = {
|
||||
...where,
|
||||
suspendedAt: { [Op.eq]: null },
|
||||
};
|
||||
}
|
||||
case "suspended": {
|
||||
if (actor.isAdmin) {
|
||||
|
||||
switch (filter) {
|
||||
case "invited": {
|
||||
where = { ...where, lastActiveAt: null };
|
||||
break;
|
||||
}
|
||||
case "suspended": {
|
||||
if (actor.isAdmin) {
|
||||
where = {
|
||||
...where,
|
||||
suspendedAt: { [Op.ne]: null },
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "active": {
|
||||
where = {
|
||||
...where,
|
||||
suspendedAt: { [Op.ne]: null },
|
||||
lastActiveAt: { [Op.ne]: null },
|
||||
suspendedAt: { [Op.is]: null },
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "all": {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
where = {
|
||||
...where,
|
||||
suspendedAt: { [Op.is]: null },
|
||||
};
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "active": {
|
||||
|
||||
if (role) {
|
||||
where = { ...where, role };
|
||||
}
|
||||
|
||||
if (query) {
|
||||
where = {
|
||||
...where,
|
||||
lastActiveAt: { [Op.ne]: null },
|
||||
suspendedAt: { [Op.is]: null },
|
||||
[Op.and]: {
|
||||
[Op.or]: [
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(email)) like unaccent(LOWER(:query))`
|
||||
),
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "all": {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
where = {
|
||||
...where,
|
||||
suspendedAt: { [Op.is]: null },
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const replacements = { query: `%${query}%` };
|
||||
|
||||
const users = await User.findAll({
|
||||
where,
|
||||
replacements,
|
||||
order: [["name", "ASC"]],
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
const presented = users.map((user) =>
|
||||
presentUser(user, {
|
||||
includeEmail: !!can(actor, "readEmail", user),
|
||||
includeDetails: !!can(actor, "readDetails", user),
|
||||
})
|
||||
);
|
||||
|
||||
return success(presented);
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
|
||||
if (role) {
|
||||
where = { ...where, role };
|
||||
}
|
||||
|
||||
if (query) {
|
||||
where = {
|
||||
...where,
|
||||
[Op.and]: {
|
||||
[Op.or]: [
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(email)) like unaccent(LOWER(:query))`
|
||||
),
|
||||
Sequelize.literal(
|
||||
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const replacements = { query: `%${query}%` };
|
||||
|
||||
const users = await User.findAll({
|
||||
where,
|
||||
replacements,
|
||||
order: [["name", "ASC"]],
|
||||
offset: effectiveOffset,
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
|
||||
const presented = users.map((user) =>
|
||||
presentUser(user, {
|
||||
includeEmail: !!can(actor, "readEmail", user),
|
||||
includeDetails: !!can(actor, "readDetails", user),
|
||||
})
|
||||
);
|
||||
|
||||
return success(presented);
|
||||
} catch (err) {
|
||||
return error(err);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { Team, User } from "@server/models";
|
||||
import { addTags } from "@server/logging/tracer";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { type APIContext, AuthenticationType } from "@server/types";
|
||||
|
||||
interface McpContext {
|
||||
@@ -72,6 +74,37 @@ export function error(err: unknown): CallToolResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an MCP tool or resource handler with Datadog tracing. Each invocation
|
||||
* creates a span under the `outline-mcp` service with the tool name as the
|
||||
* resource, and tags it with the acting user and team IDs.
|
||||
*
|
||||
* @param toolName - the name of the MCP tool or resource being traced.
|
||||
* @param handler - the handler function to wrap.
|
||||
* @returns the wrapped handler with tracing enabled.
|
||||
*/
|
||||
export function withTracing<F extends (...args: any[]) => any>(
|
||||
toolName: string,
|
||||
handler: F
|
||||
): F {
|
||||
return traceFunction({
|
||||
serviceName: "mcp",
|
||||
spanName: "tool",
|
||||
resourceName: toolName,
|
||||
})(function tracedHandler(this: any, ...args: any[]) {
|
||||
const context = args[args.length - 1];
|
||||
const user = getActorFromContext(context);
|
||||
if (user) {
|
||||
addTags({
|
||||
"mcp.tool": toolName,
|
||||
"request.userId": user.id,
|
||||
"request.teamId": user.teamId,
|
||||
});
|
||||
}
|
||||
return handler.apply(this, args);
|
||||
} as F);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to construct a URL by joining a team URL with a path segment.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user