Skip to content

Commit 3c58e68

Browse files
authored
fix: re-implement definition override and add tests (#30)
1 parent 9ae4bc5 commit 3c58e68

File tree

8 files changed

+169
-13
lines changed

8 files changed

+169
-13
lines changed

packages/nuxt-mcp-toolkit/src/runtime/server/mcp/loaders/utils.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ export function createFilePatterns(paths: string[], extensions = ['ts', 'js', 'm
7878
)
7979
}
8080

81+
/**
82+
* Create file patterns for a specific layer
83+
*/
84+
export function createLayerFilePatterns(
85+
layerServer: string,
86+
paths: string[],
87+
extensions = ['ts', 'js', 'mts', 'mjs'],
88+
): string[] {
89+
return paths.flatMap(pathPattern =>
90+
extensions.map(ext => resolvePath(layerServer, `${pathPattern}/*.${ext}`)),
91+
)
92+
}
93+
8194
export function createExcludePatterns(paths: string[], subdirs: string[]): string[] {
8295
const layerDirectories = getLayerDirectories()
8396
return layerDirectories.flatMap(layer =>
@@ -134,24 +147,35 @@ export async function loadDefinitionFiles(
134147
return { count: 0, files: [], overriddenCount: 0 }
135148
}
136149

137-
const patterns = createFilePatterns(paths)
138-
const files = await glob(patterns, {
139-
absolute: true,
140-
onlyFiles: true,
141-
ignore: options.excludePatterns,
142-
})
150+
// Get layer directories and reverse the order so that:
151+
// - Extended layers are processed first
152+
// - The main app (first in getLayerDirectories) is processed last
153+
// This allows the app to override definitions from extended layers
154+
const layerDirectories = getLayerDirectories()
155+
const reversedLayers = [...layerDirectories].reverse()
143156

144157
const definitionsMap = new Map<string, string>()
145-
const filteredFiles = options.filter ? files.filter(options.filter) : files
146158
let overriddenCount = 0
147159

148-
for (const filePath of filteredFiles) {
149-
const filename = filePath.split('/').pop()!
150-
const identifier = toIdentifier(filename)
151-
if (definitionsMap.has(identifier)) {
152-
overriddenCount++
160+
// Process each layer separately to ensure correct override order
161+
for (const layer of reversedLayers) {
162+
const layerPatterns = createLayerFilePatterns(layer.server, paths)
163+
const layerFiles = await glob(layerPatterns, {
164+
absolute: true,
165+
onlyFiles: true,
166+
ignore: options.excludePatterns,
167+
})
168+
169+
const filteredFiles = options.filter ? layerFiles.filter(options.filter) : layerFiles
170+
171+
for (const filePath of filteredFiles) {
172+
const filename = filePath.split('/').pop()!
173+
const identifier = toIdentifier(filename)
174+
if (definitionsMap.has(identifier)) {
175+
overriddenCount++
176+
}
177+
definitionsMap.set(identifier, filePath)
153178
}
154-
definitionsMap.set(identifier, filePath)
155179
}
156180

157181
const total = definitionsMap.size
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<div>override</div>
3+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default defineNuxtConfig({})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineMcpTool } from '../../../../../../../../src/runtime/server/types'
2+
import { z } from 'zod'
3+
4+
export default defineMcpTool({
5+
name: 'override_tool',
6+
title: 'Overridden Tool',
7+
description: 'This tool overrides the base tool',
8+
inputSchema: {
9+
input: z.string().describe('Test input string'),
10+
},
11+
handler: async ({ input }) => {
12+
return {
13+
content: [
14+
{
15+
type: 'text',
16+
text: `Overridden result: ${input}`,
17+
},
18+
],
19+
}
20+
},
21+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import MyModule from '../../../src/module'
2+
3+
export default defineNuxtConfig({
4+
extends: ['./layers/override-layer'],
5+
modules: [
6+
MyModule,
7+
],
8+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"private": true,
3+
"name": "override",
4+
"type": "module"
5+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineMcpTool } from '../../../../../../src/runtime/server/types'
2+
import { z } from 'zod'
3+
4+
export default defineMcpTool({
5+
name: 'override_tool',
6+
title: 'Override Tool',
7+
description: 'A tool that will be overridden by the layer',
8+
inputSchema: {
9+
input: z.string().describe('Test input string'),
10+
},
11+
handler: async ({ input }) => {
12+
return {
13+
content: [
14+
{
15+
type: 'text',
16+
text: `Base result: ${input}`,
17+
},
18+
],
19+
}
20+
},
21+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
3+
import { setup, url } from '@nuxt/test-utils/e2e'
4+
import { setupMcpClient, cleanupMcpTests, getMcpClient } from './helpers/mcp-setup.js'
5+
6+
/**
7+
* Test for MCP definition override behavior
8+
*
9+
* When multiple files with the same name exist across different layers,
10+
* only one should be loaded (no duplicates).
11+
*
12+
* Expected behavior: The app's definition overrides the layer's definition
13+
* because the app is processed last and has priority over extended layers.
14+
*/
15+
describe('Override', async () => {
16+
await setup({
17+
rootDir: fileURLToPath(new URL('./fixtures/override', import.meta.url)),
18+
})
19+
20+
beforeAll(async () => {
21+
const baseUrl = url('/')
22+
const baseUrlObj = new URL(baseUrl)
23+
const origin = `${baseUrlObj.protocol}//${baseUrlObj.host}`
24+
const mcpUrl = new URL('/mcp', origin)
25+
await setupMcpClient(mcpUrl)
26+
})
27+
28+
afterAll(async () => {
29+
await cleanupMcpTests()
30+
})
31+
32+
it('should have only one override_tool (not duplicated)', async () => {
33+
const client = getMcpClient()
34+
if (!client) {
35+
return
36+
}
37+
38+
const tools = await client.listTools()
39+
const overrideTools = tools.tools.filter(tool => tool.name === 'override_tool')
40+
41+
expect(overrideTools.length, 'There should be exactly one override_tool').toBe(1)
42+
})
43+
44+
it('should use the app tool that overrides the layer tool (app has priority)', async () => {
45+
const client = getMcpClient()
46+
if (!client) {
47+
return
48+
}
49+
50+
const inputValue = 'test input'
51+
52+
const result = await client.callTool({
53+
name: 'override_tool',
54+
arguments: {
55+
input: inputValue,
56+
},
57+
})
58+
59+
expect(result, 'Tool call should return a result').toBeDefined()
60+
expect(result.content, 'Result content should be an array').toBeInstanceOf(Array)
61+
const content = result.content as Array<{ type: string, text?: string }>
62+
63+
const textContent = content.find(c => c.type === 'text')
64+
expect(textContent, 'Result should contain text content').toBeDefined()
65+
66+
// The app's tool overrides the layer's tool (app is processed last)
67+
// So we expect "Base result" (from the app) NOT "Overridden result" (from the layer)
68+
expect(
69+
textContent?.text,
70+
`Expected the app tool to override the layer tool. Got: ${textContent?.text}`,
71+
).toContain('Base result')
72+
})
73+
})

0 commit comments

Comments
 (0)