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:
Tom Moor
2026-02-20 21:08:46 -05:00
committed by GitHub
parent 7be893f9a3
commit 519fd024f9
5 changed files with 366 additions and 310 deletions
+53 -49
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
)
);
}
}
+33
View File
@@ -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.
*