| 1 | import { defineRule } from "@oxlint/plugins"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | |
| 4 | import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; |
| 5 | |
| 6 | // Effect's MCP server, and the modules that re-export it. |
| 7 | const MCP_SERVER_MODULE = "effect/ai/McpServer"; |
| 8 | const 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... |
| 11 | const 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. |
| 20 | const SERVICE_REGISTRATIONS = new Set([ |
| 21 | "addTool", |
| 22 | "addResource", |
| 23 | "addResourceTemplate", |
| 24 | "addPrompt", |
| 25 | ]); |
| 26 | const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; |
| 27 | |
| 28 | const HOW = |
| 29 | "Build the handlers with McpToolAccess.toLayer and register them through McpHttpServer's toolkitRegistration."; |
| 30 | const registrationMessage = (name: string) => |
| 31 | `${name} registers on /mcp without the access checks McpToolAccess declares. ${HOW}`; |
| 32 | const importMessage = `Only McpHttpServer may use Effect's McpServer: a registration anywhere else skips the access checks McpToolAccess declares. ${HOW}`; |
| 33 | const 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. */ |
| 37 | const 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 | |
| 48 | const namesMcpServer = (node: unknown) => |
| 49 | Option.getOrUndefined(getPropertyName(node)) === "McpServer"; |
| 50 | |
| 51 | const 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 | */ |
| 64 | export 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 | }); |