Files
Tom Moor ff3b3ce552 fix: Allow empty string in optional MCP fields (#12310)
* fix: Allow empty string in optional fields

* fix: Preserve empty strings for content fields in MCP tools

Address review feedback by reverting content/text fields (description, document
text, comment text) back to z.string().optional() so callers can intentionally
clear values via "". optionalString() is reserved for identifier and query
fields where "" is not a meaningful input.
2026-05-10 10:47:24 -04:00

177 lines
5.1 KiB
TypeScript

import { z } from "zod";
import { Op, Sequelize } from "sequelize";
import type { WhereOptions } from "sequelize";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { UserRole } from "@shared/types";
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,
optionalString,
withTracing,
} from "./util";
/**
* Registers user-related MCP tools on the given server, filtered by the
* OAuth scopes granted to the current token.
*
* @param server - the MCP server instance to register on.
* @param scopes - the OAuth scopes granted to the access token.
*/
export function userTools(server: McpServer, scopes: string[]) {
if (AuthenticationHelper.canAccess("users.list", scopes)) {
server.registerTool(
"list_users",
{
title: "List users",
description: "Lists users in the workspace.",
annotations: {
idempotentHint: true,
readOnlyHint: true,
},
inputSchema: {
query: optionalString().describe(
"An optional search query to filter users by name or email."
),
role: z
.enum([
UserRole.Admin,
UserRole.Member,
UserRole.Viewer,
UserRole.Guest,
])
.optional()
.describe("Filter users by role."),
filter: z
.enum(["active", "suspended", "invited", "all"])
.optional()
.describe(
"Filter users by status. Defaults to active, non-suspended users. Note filtering by 'suspended' is only available to admins."
),
offset: z.coerce
.number()
.int()
.min(0)
.optional()
.describe("The pagination offset. Defaults to 0."),
limit: z.coerce
.number()
.int()
.min(1)
.max(100)
.optional()
.describe(
"The maximum number of results to return. Defaults to 25, max 100."
),
},
},
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;
let where: WhereOptions<User> = {
teamId: actor.teamId,
};
// Non-admins cannot see suspended users
if (!actor.isAdmin) {
where = {
...where,
suspendedAt: { [Op.eq]: null },
};
}
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,
lastActiveAt: { [Op.ne]: null },
suspendedAt: { [Op.is]: null },
};
break;
}
case "all": {
break;
}
default: {
where = {
...where,
suspendedAt: { [Op.is]: null },
};
break;
}
}
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);
}
}
)
);
}
}