1import { defineRule } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5
6// Effect's MCP server, and the modules that re-export it.
7const MCP_SERVER_MODULE = "effect/ai/McpServer";
8const AI_MODULES = new Set(["effect/ai", "effect/ai/index"]);
9// Registering anything straight on the MCP server skips McpToolAccess, where
10// every T3 MCP tool declares who may call it. These are the module functions...
11const MODULE_REGISTRATIONS = new Set([
12 "toolkit",
13 "registerToolkit",
14 "resource",
15 "registerResource",
16 "prompt",
17 "registerPrompt",
18]);
19// ...and the methods on the McpServer service itself.
20const SERVICE_REGISTRATIONS = new Set([
21 "addTool",
22 "addResource",
23 "addResourceTemplate",
24 "addPrompt",
25]);
26const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
27
28const HOW =
29 "Build the handlers with McpToolAccess.toLayer and register them through McpHttpServer's toolkitRegistration.";
30const registrationMessage = (name: string) =>
31 `${name} registers on /mcp without the access checks McpToolAccess declares. ${HOW}`;
32const importMessage = `Only McpHttpServer may use Effect's McpServer: a registration anywhere else skips the access checks McpToolAccess declares. ${HOW}`;
33const testImportMessage =
34 'Tests import McpServer only as `import { McpServer } from "effect/ai"`, so a registration on it is reported.';
35
36/** A module specifier: a string, or a template with nothing substituted. */
37const literalString = (node: unknown): Option.Option<string> =>
38 Option.flatMap(unwrapExpression(node), (expression) => {
39 if (expression.type === "Literal" && typeof expression.value === "string") {
40 return Option.some(expression.value);
41 }
42 if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) {
43 return Option.fromNullishOr(expression.quasis[0]?.value.cooked);
44 }
45 return Option.none();
46 });
47
48const namesMcpServer = (node: unknown) =>
49 Option.getOrUndefined(getPropertyName(node)) === "McpServer";
50
51const exposesMcpServer = (source: Option.Option<string>) =>
52 Option.isSome(source) && (source.value === MCP_SERVER_MODULE || AI_MODULES.has(source.value));
53
54/**
55 * Keeps every registration on `/mcp` inside McpHttpServer, whose helpers
56 * accept only handlers McpToolAccess built; the lint config exempts that file.
57 * Elsewhere it reports runtime imports of Effect's McpServer, under any name
58 * or path, and reading a registration method or function in any form. Tests
59 * may import McpServer to build a server, but only under its own name, so a
60 * registration on it is still reported. It guards against a registration that
61 * skips the checks by accident; deliberately working around it needs a
62 * reviewed change here.
63 */
64export default defineRule({
65 meta: {
66 type: "problem",
67 docs: {
68 description:
69 "Disallow registering on the MCP server without McpToolAccess; tools must declare who may call them.",
70 },
71 },
72 create(context) {
73 const isTest = TEST_FILE_PATTERN.test(context.filename);
74 return {
75 ImportDeclaration(node) {
76 if (node.importKind === "type") return;
77 const source = literalString(node.source);
78 if (Option.isNone(source)) return;
79 const exposes = node.specifiers.some((specifier) => {
80 if (specifier.type !== "ImportSpecifier") return exposesMcpServer(source);
81 if (specifier.importKind === "type") return false;
82 if (source.value === MCP_SERVER_MODULE) return true;
83 return (
84 AI_MODULES.has(source.value) &&
85 namesMcpServer(specifier.imported) &&
86 // A test's `McpServer.toolkit(...)` is reported below by name.
87 !(isTest && namesMcpServer(specifier.local))
88 );
89 });
90 if (exposes) context.report({ node, message: isTest ? testImportMessage : importMessage });
91 },
92 ExportNamedDeclaration(node) {
93 if (node.source === null || node.exportKind === "type") return;
94 const source = literalString(node.source);
95 if (Option.isNone(source)) return;
96 const exposes = node.specifiers.some(
97 (specifier) =>
98 specifier.exportKind !== "type" &&
99 (source.value === MCP_SERVER_MODULE ||
100 (AI_MODULES.has(source.value) && namesMcpServer(specifier.local))),
101 );
102 if (exposes) context.report({ node, message: importMessage });
103 },
104 ExportAllDeclaration(node) {
105 if (node.exportKind === "type") return;
106 if (exposesMcpServer(literalString(node.source))) {
107 context.report({ node, message: importMessage });
108 }
109 },
110 ImportExpression(node) {
111 if (exposesMcpServer(literalString(node.source))) {
112 context.report({ node, message: importMessage });
113 }
114 },
115 MemberExpression(node) {
116 const name = getPropertyName(node.property);
117 if (Option.isNone(name)) return;
118 if (SERVICE_REGISTRATIONS.has(name.value)) {
119 context.report({ node, message: registrationMessage(`.${name.value}`) });
120 } else if (
121 MODULE_REGISTRATIONS.has(name.value) &&
122 isIdentifier(unwrapExpression(node.object), "McpServer")
123 ) {
124 context.report({ node, message: registrationMessage(`McpServer.${name.value}`) });
125 }
126 },
127 // `const { addTool } = server`, `const { toolkit } = McpServer`
128 ObjectPattern(node) {
129 const fromMcpServer =
130 node.parent.type === "VariableDeclarator" &&
131 isIdentifier(unwrapExpression(node.parent.init), "McpServer");
132 for (const property of node.properties) {
133 if (property.type !== "Property") continue;
134 const name = getPropertyName(property.key);
135 if (Option.isNone(name)) continue;
136 if (SERVICE_REGISTRATIONS.has(name.value)) {
137 context.report({ node: property, message: registrationMessage(`.${name.value}`) });
138 } else if (fromMcpServer && MODULE_REGISTRATIONS.has(name.value)) {
139 context.report({
140 node: property,
141 message: registrationMessage(`McpServer.${name.value}`),
142 });
143 }
144 }
145 },
146 };
147 },
148});