Skip to content
Source
Sign inSign up

no-raw-mcp-registration.ts

131 lines5.1 KB

1import { defineRule } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3​
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5​
6const MCP_SERVER_MODULE = "effect/ai/McpServer";
7const AI_MODULES = new Set(["effect/ai", "effect/ai/index"]);
8const MODULE_REGISTRATIONS = new Set([
9 "toolkit",
10 "registerToolkit",
11 "resource",
12 "registerResource",
13 "prompt",
14 "registerPrompt",
15]);
16const SERVICE_REGISTRATIONS = new Set([
17 "addTool",
18 "addResource",
19 "addResourceTemplate",
20 "addPrompt",
21]);
22const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
23​
24const HOW =
25 "Build the handlers with McpToolAccess.toLayer and register them through McpHttpServer's toolkitRegistration.";
26const registrationMessage = (name: string) =>
27 `${name} registers on /mcp without the access checks McpToolAccess declares. ${HOW}`;
28const importMessage = `Only McpHttpServer may use Effect's McpServer: a registration anywhere else skips the access checks McpToolAccess declares. ${HOW}`;
29const testImportMessage =
30 'Tests import McpServer only as `import { McpServer } from "effect/ai"`, so a registration on it is reported.';
31​
32const literalString = (node: unknown): Option.Option<string> =>
33 Option.flatMap(unwrapExpression(node), (expression) => {
34 if (expression.type === "Literal" && typeof expression.value === "string") {
35 return Option.some(expression.value);
36 }
37 if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) {
38 return Option.fromNullishOr(expression.quasis[0]?.value.cooked);
39 }
40 return Option.none();
41 });
42​
43const namesMcpServer = (node: unknown) =>
44 Option.getOrUndefined(getPropertyName(node)) === "McpServer";
45​
46const exposesMcpServer = (source: Option.Option<string>) =>
47 Option.isSome(source) && (source.value === MCP_SERVER_MODULE || AI_MODULES.has(source.value));
48​
49export default defineRule({
50 meta: {
51 type: "problem",
52 docs: {
53 description:
54 "Disallow registering on the MCP server without McpToolAccess; tools must declare who may call them.",
55 },
56 },
57 create(context) {
58 const isTest = TEST_FILE_PATTERN.test(context.filename);
59 return {
60 ImportDeclaration(node) {
61 if (node.importKind === "type") return;
62 const source = literalString(node.source);
63 if (Option.isNone(source)) return;
64 const exposes = node.specifiers.some((specifier) => {
65 if (specifier.type !== "ImportSpecifier") return exposesMcpServer(source);
66 if (specifier.importKind === "type") return false;
67 if (source.value === MCP_SERVER_MODULE) return true;
68 return (
69 AI_MODULES.has(source.value) &&
70 namesMcpServer(specifier.imported) &&
71 !(isTest && namesMcpServer(specifier.local))
72 );
73 });
74 if (exposes) context.report({ node, message: isTest ? testImportMessage : importMessage });
75 },
76 ExportNamedDeclaration(node) {
77 if (node.source === null || node.exportKind === "type") return;
78 const source = literalString(node.source);
79 if (Option.isNone(source)) return;
80 const exposes = node.specifiers.some(
81 (specifier) =>
82 specifier.exportKind !== "type" &&
83 (source.value === MCP_SERVER_MODULE ||
84 (AI_MODULES.has(source.value) && namesMcpServer(specifier.local))),
85 );
86 if (exposes) context.report({ node, message: importMessage });
87 },
88 ExportAllDeclaration(node) {
89 if (node.exportKind === "type") return;
90 if (exposesMcpServer(literalString(node.source))) {
91 context.report({ node, message: importMessage });
92 }
93 },
94 ImportExpression(node) {
95 if (exposesMcpServer(literalString(node.source))) {
96 context.report({ node, message: importMessage });
97 }
98 },
99 MemberExpression(node) {
100 const name = getPropertyName(node.property);
101 if (Option.isNone(name)) return;
102 if (SERVICE_REGISTRATIONS.has(name.value)) {
103 context.report({ node, message: registrationMessage(`.${name.value}`) });
104 } else if (
105 MODULE_REGISTRATIONS.has(name.value) &&
106 isIdentifier(unwrapExpression(node.object), "McpServer")
107 ) {
108 context.report({ node, message: registrationMessage(`McpServer.${name.value}`) });
109 }
110 },
111 ObjectPattern(node) {
112 const fromMcpServer =
113 node.parent.type === "VariableDeclarator" &&
114 isIdentifier(unwrapExpression(node.parent.init), "McpServer");
115 for (const property of node.properties) {
116 if (property.type !== "Property") continue;
117 const name = getPropertyName(property.key);
118 if (Option.isNone(name)) continue;
119 if (SERVICE_REGISTRATIONS.has(name.value)) {
120 context.report({ node: property, message: registrationMessage(`.${name.value}`) });
121 } else if (fromMcpServer && MODULE_REGISTRATIONS.has(name.value)) {
122 context.report({
123 node: property,
124 message: registrationMessage(`McpServer.${name.value}`),
125 });
126 }
127 }
128 },
129 };
130 },
131});