mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
ff3b3ce552
* 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.
177 lines
5.1 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
)
|
|
);
|
|
}
|
|
}
|