Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ This product includes the following bundled third-party software:
- @modelcontextprotocol/ext-apps v1.7.5 [MIT] (used by: @nvidia-elements/cli)
Copyright: Olivier Chafik

- @modelcontextprotocol/sdk v1.30.0 [MIT] (used by: @nvidia-elements/cli)
- @modelcontextprotocol/server v2.0.0 [MIT] (used by: @nvidia-elements/cli)
Copyright: Anthropic, PBC (https://anthropic.com)

- adm-zip v0.5.17 [MIT] (used by: @nvidia-elements/cli)
Expand Down Expand Up @@ -308,7 +308,7 @@ The following bundled components are provided under the MIT license:
@html-eslint/parser v0.61.0 - Copyright yeonjuan (https://github.com/yeonjuan)
@inquirer/prompts v8.5.0 - Copyright Simon Boudrias <admin@simonboudrias.com>
@modelcontextprotocol/ext-apps v1.7.5 - Copyright Olivier Chafik
@modelcontextprotocol/sdk v1.30.0 - Copyright Anthropic, PBC (https://anthropic.com)
@modelcontextprotocol/server v2.0.0 - Copyright Anthropic, PBC (https://anthropic.com)
adm-zip v0.5.17 - Copyright Nasca Iacob <sy@another-d-mention.ro> (https://github.com/cthackers)
archiver v8.0.0 - Copyright Chris Talkington (http://christalkington.com/)
markdown-it v14.3.0 - Copyright Unknown
Expand Down
23 changes: 20 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 9 additions & 8 deletions projects/cli/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
The package has two entry points:

- **`./dist/index.js`** (`nve` command) - Interactive CLI using Yargs with Inquirer for prompts
- **`./dist/mcp/index.js`** (`nve mcp` command) - MCP server using @modelcontextprotocol/sdk
- **`./dist/mcp/index.js`** (`nve mcp` command) - MCP server using `@modelcontextprotocol/server`

### Tool System

Expand Down Expand Up @@ -60,7 +60,7 @@ tools.forEach(tool => {
#### MCP Mode (`src/mcp/index.ts`)

- Sets `process.env.ELEMENTS_ENV = 'mcp'`
- Creates MCP server instance with stdio transport
- Serves an MCP server factory over stdio
- **Tool registration** - Registers all tools with MCP server using Zod schemas
- **Prompt registration** - Registers 6 built-in prompts (about, doctor, search, playground, new-project, migrate)
- **Structured output** - Returns results with status, message, and structured content
Expand All @@ -69,13 +69,14 @@ Example MCP tool registration:

```typescript
// Convert JSON Schema to Zod and register with MCP
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map(tool => ({
name: tool.toolName,
server.registerTool(
tool.toolName,
{
description: tool.description,
inputSchema: zodToJsonSchema(jsonSchemaToZod(tool.inputSchema))
}))
}));
inputSchema: jsonSchemaToZod(tool.inputSchema)
},
async params => tool(params)
);
```

## Data Flow
Expand Down
4 changes: 2 additions & 2 deletions projects/cli/NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This project includes the following bundled third-party software:
- @modelcontextprotocol/ext-apps v1.7.5 [MIT]
Copyright: Olivier Chafik

- @modelcontextprotocol/sdk v1.30.0 [MIT]
- @modelcontextprotocol/server v2.0.0 [MIT]
Copyright: Anthropic, PBC (https://anthropic.com)

- adm-zip v0.5.17 [MIT]
Expand Down Expand Up @@ -49,7 +49,7 @@ The following bundled components are provided under the MIT license:

@inquirer/prompts v8.5.0 - Copyright Simon Boudrias <admin@simonboudrias.com>
@modelcontextprotocol/ext-apps v1.7.5 - Copyright Olivier Chafik
@modelcontextprotocol/sdk v1.30.0 - Copyright Anthropic, PBC (https://anthropic.com)
@modelcontextprotocol/server v2.0.0 - Copyright Anthropic, PBC (https://anthropic.com)
adm-zip v0.5.17 - Copyright Nasca Iacob <sy@another-d-mention.ro> (https://github.com/cthackers)
archiver v8.0.0 - Copyright Chris Talkington (http://christalkington.com/)
marked v18.0.3 - Copyright Christopher Jeffrey
Expand Down
1 change: 1 addition & 0 deletions projects/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,4 @@ This hosted route does not install the Elements CLI or configure the MCP server.
- [Changelog](https://NVIDIA.github.io/elements/docs/changelog/)
- [GitHub Repo](https://github.com/NVIDIA/elements)
- [npm](https://www.npmjs.com/package/@nvidia-elements/cli)
- [MCP Registry](https://registry.modelcontextprotocol.io/?q=io.github.NVIDIA%2Felements)
4 changes: 2 additions & 2 deletions projects/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"dist/**/*.js"
],
"scripts": {
"dev": "pnpm run cli:install && npx @modelcontextprotocol/inspector@0.22.0 node ./dist/index.js mcp",
"dev": "pnpm run cli:install && npx @modelcontextprotocol/inspector@2.0.0 node ./dist/index.js mcp",
"ci": "wireit",
"build": "wireit",
"lint": "wireit",
Expand All @@ -47,8 +47,8 @@
},
"dependencies": {
"@inquirer/prompts": "8.5.0",
"@modelcontextprotocol/sdk": "catalog:",
"@modelcontextprotocol/ext-apps": "catalog:",
"@modelcontextprotocol/server": "2.0.0",
"@nvidia-elements/code": "workspace:*",
"@nvidia-elements/lint": "workspace:^",
"adm-zip": "0.5.17",
Expand Down
97 changes: 70 additions & 27 deletions projects/cli/src/mcp/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ const {
mockRegisterPrompt,
mockRegisterResource,
mockRegisterCapabilities,
mockConnect,
mockServeStdio,
mockNotify,
mcpTool,
cliTool,
allTool,
uiTool,
mockPrompt,
mockZodSchema
mockPrompt
} = vi.hoisted(() => {
const zodSchema = { shape: {}, optional: () => zodSchema };
const stdioHandle = { close: vi.fn().mockResolvedValue(undefined) };

const createMockTool = (overrides: Record<string, unknown>) => {
const fn = vi.fn().mockResolvedValue({ status: 'complete', result: 'test' });
Expand All @@ -40,8 +40,11 @@ const {
mockRegisterPrompt: vi.fn(),
mockRegisterResource: vi.fn(),
mockRegisterCapabilities: vi.fn(),
mockConnect: vi.fn().mockResolvedValue(undefined),
mockZodSchema: zodSchema,
mockServeStdio: vi.fn((factory: () => unknown) => {
factory();
return stdioHandle;
}),
mockNotify: vi.fn().mockResolvedValue(undefined),
mcpTool: createMockTool({
support: 1,
toolName: 'mcp_tool',
Expand Down Expand Up @@ -83,28 +86,37 @@ const {
};
});

vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
vi.mock('@modelcontextprotocol/server', () => ({
McpServer: vi.fn(function () {
return {
registerTool: mockRegisterTool,
registerPrompt: mockRegisterPrompt,
registerResource: mockRegisterResource,
server: { registerCapabilities: mockRegisterCapabilities },
connect: mockConnect
server: { registerCapabilities: mockRegisterCapabilities }
};
})
}));

vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn()
vi.mock('@modelcontextprotocol/server/stdio', () => ({
serveStdio: mockServeStdio
}));

vi.mock('@internals/tools', () => ({
tools: [mcpTool, cliTool, allTool, uiTool],
prompts: [mockPrompt],
jsonSchemaToZod: vi.fn(() => mockZodSchema),
ToolSupport: { None: 0, MCP: 1, CLI: 2, All: 3 }
}));
vi.mock('@internals/tools', async () => {
const { default: z } = await import('zod');
return {
tools: [mcpTool, cliTool, allTool, uiTool],
prompts: [mockPrompt],
jsonSchemaToZod: vi.fn(() => z.object({})),
ToolSupport: { None: 0, MCP: 1, CLI: 2, All: 3 }
};
});

const createRequestContext = () => ({
mcpReq: {
_meta: {},
notify: mockNotify
}
});

describe('MCP server', () => {
beforeEach(() => {
Expand Down Expand Up @@ -151,12 +163,12 @@ describe('MCP server', () => {
);
});

it('should handle tools without inputSchema', async () => {
it('should register tools without inputSchema using an empty Zod object', async () => {
const { startMcpServer } = await import('./index.js');
await startMcpServer();
// mcpTool has no inputSchema — should still register without error
const mcpToolCall = mockRegisterTool.mock.calls.find(call => call[0] === 'mcp_tool');
expect(mcpToolCall[1].inputSchema).toEqual({});
expect(mcpToolCall[1].inputSchema.shape).toEqual({});
});

it('should register prompts', async () => {
Expand All @@ -182,17 +194,19 @@ describe('MCP server', () => {
expect(result).toEqual({ messages: [] });
});

it('should connect to stdio transport', async () => {
it('should serve an MCP server factory over stdio', async () => {
const { startMcpServer } = await import('./index.js');
await startMcpServer();
expect(mockConnect).toHaveBeenCalledTimes(1);
expect(mockServeStdio).toHaveBeenCalledWith(expect.any(Function), {
onerror: expect.any(Function)
});
});

it('should return string result as text content', async () => {
const { startMcpServer } = await import('./index.js');
await startMcpServer();
const handler = mockRegisterTool.mock.calls[0][2];
const result = await handler({});
const result = await handler({}, createRequestContext());
expect(result).toEqual({
structuredContent: { status: 'complete', result: 'test' },
content: [{ type: 'text', text: 'test' }]
Expand All @@ -205,7 +219,7 @@ describe('MCP server', () => {
const handler = mockRegisterTool.mock.calls[0][2];
const errorResult = { status: 'error', message: 'failed' };
mcpTool.mockResolvedValueOnce(errorResult);
const result = await handler({});
const result = await handler({}, createRequestContext());
expect(result.content[0].text).toBe(JSON.stringify(errorResult));
});

Expand All @@ -215,10 +229,37 @@ describe('MCP server', () => {
const handler = mockRegisterTool.mock.calls[0][2];
const objResult = { status: 'complete', result: { key: 'value' } };
mcpTool.mockResolvedValueOnce(objResult);
const result = await handler({});
const result = await handler({}, createRequestContext());
expect(result.content[0].text).toBe(JSON.stringify(objResult));
});

it('should report progress through the v2 request context', async () => {
const { startMcpServer } = await import('./index.js');
await startMcpServer();
const handler = mockRegisterTool.mock.calls[0][2];
const params: Record<string, unknown> = {};
await handler(params, {
mcpReq: {
_meta: { progressToken: 'progress-token' },
notify: mockNotify
}
});

if (typeof params.onProgress !== 'function') {
throw new TypeError('Expected an onProgress callback');
}
params.onProgress('Loading metadata');

expect(mockNotify).toHaveBeenCalledWith({
method: 'notifications/progress',
params: {
progressToken: 'progress-token',
progress: 1,
message: 'Loading metadata'
}
});
});

it('should advertise the io.modelcontextprotocol/ui extension capability', async () => {
const { startMcpServer } = await import('./index.js');
await startMcpServer();
Expand Down Expand Up @@ -359,15 +400,17 @@ describe('MCP server', () => {
expect(mcpToolCall![1]._meta).toBeUndefined();
});

it('should exit on connection error', async () => {
mockConnect.mockRejectedValueOnce(new Error('Connection failed'));
it('should exit on stdio error', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const { startMcpServer } = await import('./index.js');
await startMcpServer();
const options = mockServeStdio.mock.calls[0][1];
const error = new Error('Connection failed');
options.onerror(error);

expect(errorSpy).toHaveBeenCalledWith(expect.any(Error));
expect(errorSpy).toHaveBeenCalledWith(error);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
Loading