Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions core/src/browser/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Inference = 'inference',
Model = 'model',
SystemMonitoring = 'systemMonitoring',
MCP = 'mcp',
HuggingFace = 'huggingFace',
Engine = 'engine',
Hardware = 'hardware',
Expand Down Expand Up @@ -90,18 +91,18 @@
* @property {Array} platform
*/
compatibility(): Compatibility | undefined {
return undefined
}

Check warning on line 95 in core/src/browser/extension.ts

View workflow job for this annotation

GitHub Actions / coverage-check

94-95 lines are not covered with tests

/**
* Registers models - it persists in-memory shared ModelManager instance's data map.
* @param models
*/
async registerModels(models: Model[]): Promise<void> {
for (const model of models) {
ModelManager.instance().register(model)
}
}

Check warning on line 105 in core/src/browser/extension.ts

View workflow job for this annotation

GitHub Actions / coverage-check

102-105 lines are not covered with tests

/**
* Register settings for the extension.
Expand All @@ -110,9 +111,9 @@
*/
async registerSettings(settings: SettingComponentProps[]): Promise<void> {
if (!this.name) {
console.error('Extension name is not defined')
return
}

Check warning on line 116 in core/src/browser/extension.ts

View workflow job for this annotation

GitHub Actions / coverage-check

114-116 lines are not covered with tests

settings.forEach((setting) => {
setting.extensionName = this.name
Expand All @@ -121,29 +122,29 @@
const oldSettingsJson = localStorage.getItem(this.name)
// Persists new settings
if (oldSettingsJson) {
const oldSettings = JSON.parse(oldSettingsJson)
settings.forEach((setting) => {

Check warning on line 126 in core/src/browser/extension.ts

View workflow job for this annotation

GitHub Actions / coverage-check

125-126 lines are not covered with tests
// Keep setting value
if (setting.controllerProps && Array.isArray(oldSettings))
setting.controllerProps.value = oldSettings.find(
(e: any) => e.key === setting.key
)?.controllerProps?.value
if ('options' in setting.controllerProps)
setting.controllerProps.options = setting.controllerProps.options?.length
? setting.controllerProps.options
: oldSettings.find((e: any) => e.key === setting.key)?.controllerProps?.options
if ('recommended' in setting.controllerProps) {
const oldRecommended = oldSettings.find((e: any) => e.key === setting.key)?.controllerProps?.recommended
if (oldRecommended !== undefined && oldRecommended !== "") {
setting.controllerProps.recommended = oldRecommended
}
}
})
}

Check warning on line 143 in core/src/browser/extension.ts

View workflow job for this annotation

GitHub Actions / coverage-check

128-143 lines are not covered with tests
localStorage.setItem(this.name, JSON.stringify(settings))
} catch (err) {
console.error(err)
}

Check warning on line 147 in core/src/browser/extension.ts

View workflow job for this annotation

GitHub Actions / coverage-check

146-147 lines are not covered with tests
}

/**
Expand Down Expand Up @@ -177,18 +178,18 @@
* @returns
*/
async getSettings(): Promise<SettingComponentProps[]> {
if (!this.name) return []

Check warning on line 181 in core/src/browser/extension.ts

View workflow job for this annotation

GitHub Actions / coverage-check

181 line is not covered with tests

try {
const settingsString = localStorage.getItem(this.name)
if (!settingsString) return []
const settings: SettingComponentProps[] = JSON.parse(settingsString)
return settings
} catch (err) {
console.warn(err)
return []
}
}

Check warning on line 192 in core/src/browser/extension.ts

View workflow job for this annotation

GitHub Actions / coverage-check

183-192 lines are not covered with tests

/**
* Update the settings for the extension.
Expand Down
5 changes: 5 additions & 0 deletions core/src/browser/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export { InferenceExtension } from './inference'
*/
export { AssistantExtension } from './assistant'

/**
* MCP extension for managing tools and server communication.
*/
export { MCPExtension } from './mcp'

/**
* Base AI Engines.
*/
Expand Down
99 changes: 99 additions & 0 deletions core/src/browser/extensions/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { MCPExtension } from './mcp'
import { ExtensionTypeEnum } from '../extension'
import { MCPTool, MCPToolCallResult } from '../../types'

class TestMCPExtension extends MCPExtension {
constructor() {
super('test://mcp', 'test-mcp')
}

async getTools(): Promise<MCPTool[]> {
return [
{
name: 'test_tool',
description: 'A test tool',
inputSchema: { type: 'object' },
server: 'test-server'
}
]
}

async callTool(toolName: string, args: Record<string, unknown>): Promise<MCPToolCallResult> {
return {
error: '',
content: [{ type: 'text', text: `Called ${toolName} with ${JSON.stringify(args)}` }]
}
}

async getConnectedServers(): Promise<string[]> {
return ['test-server']
}

async refreshTools(): Promise<void> {
// Mock implementation
}

async isHealthy(): Promise<boolean> {
return true
}

async onLoad(): Promise<void> {
// Mock implementation
}

onUnload(): void {
// Mock implementation
}
}

describe('MCPExtension', () => {
let mcpExtension: TestMCPExtension

beforeEach(() => {
mcpExtension = new TestMCPExtension()
})

describe('type', () => {
it('should return MCP extension type', () => {
expect(mcpExtension.type()).toBe(ExtensionTypeEnum.MCP)
})
})

describe('getTools', () => {
it('should return array of MCP tools', async () => {
const tools = await mcpExtension.getTools()
expect(tools).toHaveLength(1)
expect(tools[0]).toEqual({
name: 'test_tool',
description: 'A test tool',
inputSchema: { type: 'object' },
server: 'test-server'
})
})
})

describe('callTool', () => {
it('should call tool and return result', async () => {
const result = await mcpExtension.callTool('test_tool', { param: 'value' })
expect(result).toEqual({
error: '',
content: [{ type: 'text', text: 'Called test_tool with {"param":"value"}' }]
})
})
})

describe('getConnectedServers', () => {
it('should return list of connected servers', async () => {
const servers = await mcpExtension.getConnectedServers()
expect(servers).toEqual(['test-server'])
})
})

describe('isHealthy', () => {
it('should return health status', async () => {
const healthy = await mcpExtension.isHealthy()
expect(healthy).toBe(true)
})
})
})
21 changes: 21 additions & 0 deletions core/src/browser/extensions/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MCPInterface, MCPTool, MCPToolCallResult } from '../../types'
import { BaseExtension, ExtensionTypeEnum } from '../extension'

/**
* MCP (Model Context Protocol) extension for managing tools and server communication.
* @extends BaseExtension
*/
export abstract class MCPExtension extends BaseExtension implements MCPInterface {
/**
* MCP extension type.
*/
type(): ExtensionTypeEnum | undefined {
return ExtensionTypeEnum.MCP
}

abstract getTools(): Promise<MCPTool[]>
abstract callTool(toolName: string, args: Record<string, unknown>): Promise<MCPToolCallResult>
abstract getConnectedServers(): Promise<string[]>
abstract refreshTools(): Promise<void>
abstract isHealthy(): Promise<boolean>
}
1 change: 1 addition & 0 deletions core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './api'
export * from './setting'
export * from './engine'
export * from './hardware'
export * from './mcp'
2 changes: 2 additions & 0 deletions core/src/types/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './mcpEntity'
export * from './mcpInterface'
24 changes: 24 additions & 0 deletions core/src/types/mcp/mcpEntity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* MCP (Model Context Protocol) entities
*/

export interface MCPTool {
name: string
description: string
inputSchema: Record<string, unknown>
server: string
}

export interface MCPToolCallResult {
error: string
content: Array<{
type?: string
text: string
}>
}

export interface MCPServerInfo {
name: string
connected: boolean
tools?: MCPTool[]
}
32 changes: 32 additions & 0 deletions core/src/types/mcp/mcpInterface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* MCP (Model Context Protocol) interface
*/

import { MCPTool, MCPToolCallResult } from './mcpEntity'

export interface MCPInterface {
/**
* Get all available MCP tools
*/
getTools(): Promise<MCPTool[]>

/**
* Call a specific MCP tool
*/
callTool(toolName: string, args: Record<string, unknown>): Promise<MCPToolCallResult>

/**
* Get list of connected MCP servers
*/
getConnectedServers(): Promise<string[]>

/**
* Refresh the list of available tools
*/
refreshTools(): Promise<void>

/**
* Check if MCP service is healthy
*/
isHealthy(): Promise<boolean>
}
3 changes: 3 additions & 0 deletions extensions-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,8 @@
"peerDependencies": {
"@janhq/core": "*",
"zustand": "^5.0.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.5"
}
}
5 changes: 4 additions & 1 deletion extensions-web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@

import type { WebExtensionRegistry } from './types'

export { default as AssistantExtensionWeb } from './assistant-web'
export { default as ConversationalExtensionWeb } from './conversational-web'
export { default as JanProviderWeb } from './jan-provider-web'
export { default as MCPExtensionWeb } from './mcp-web'

Check warning on line 11 in extensions-web/src/index.ts

View workflow job for this annotation

GitHub Actions / coverage-check

8-11 lines are not covered with tests

// Re-export types
export type {
Expand All @@ -17,12 +18,14 @@
WebExtensionLoader,
AssistantWebModule,
ConversationalWebModule,
JanProviderWebModule
JanProviderWebModule,
MCPWebModule
} from './types'

// Extension registry for dynamic loading
export const WEB_EXTENSIONS: WebExtensionRegistry = {
'assistant-web': () => import('./assistant-web'),
'conversational-web': () => import('./conversational-web'),
'jan-provider-web': () => import('./jan-provider-web'),
'mcp-web': () => import('./mcp-web'),
}

Check warning on line 31 in extensions-web/src/index.ts

View workflow job for this annotation

GitHub Actions / coverage-check

26-31 lines are not covered with tests
38 changes: 7 additions & 31 deletions extensions-web/src/jan-provider-web/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Handles API requests to Jan backend for models and chat completions
*/

import { JanAuthService } from './auth'
import { JanAuthService } from '../shared/auth'
import { JanModel, janProviderStore } from './store'

// JAN_API_BASE is defined in vite.config.ts
Expand All @@ -18,6 +18,7 @@ export interface JanChatMessage {
content: string
reasoning?: string
reasoning_content?: string
tool_calls?: any[]
}

export interface JanChatCompletionRequest {
Expand All @@ -30,6 +31,8 @@ export interface JanChatCompletionRequest {
presence_penalty?: number
stream?: boolean
stop?: string | string[]
tools?: any[]
tool_choice?: any
}

export interface JanChatCompletionChoice {
Expand Down Expand Up @@ -63,6 +66,7 @@ export interface JanChatCompletionChunk {
content?: string
reasoning?: string
reasoning_content?: string
tool_calls?: any[]
}
finish_reason: string | null
}>
Expand All @@ -83,40 +87,12 @@ export class JanApiClient {
return JanApiClient.instance
}

private async makeAuthenticatedRequest<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
try {
const authHeader = await this.authService.getAuthHeader()

const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeader,
...options.headers,
},
})

if (!response.ok) {
const errorText = await response.text()
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
}

return response.json()
} catch (error) {
console.error('API request failed:', error)
throw error
}
}

async getModels(): Promise<JanModel[]> {
try {
janProviderStore.setLoadingModels(true)
janProviderStore.clearError()

const response = await this.makeAuthenticatedRequest<JanModelsResponse>(
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
`${JAN_API_BASE}/models`
)

Expand All @@ -138,7 +114,7 @@ export class JanApiClient {
try {
janProviderStore.clearError()

return await this.makeAuthenticatedRequest<JanChatCompletionResponse>(
return await this.authService.makeAuthenticatedRequest<JanChatCompletionResponse>(
`${JAN_API_BASE}/chat/completions`,
{
method: 'POST',
Expand Down
12 changes: 9 additions & 3 deletions extensions-web/src/jan-provider-web/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export default class JanProviderWeb extends AIEngine {
path: undefined, // Remote model, no local path
owned_by: model.owned_by,
object: model.object,
capabilities: ['tools'], // Jan models support both tools via MCP
}))
} catch (error) {
console.error('Failed to list Jan models:', error)
Expand Down Expand Up @@ -150,6 +151,8 @@ export default class JanProviderWeb extends AIEngine {
presence_penalty: opts.presence_penalty ?? undefined,
stream: opts.stream ?? false,
stop: opts.stop ?? undefined,
tools: opts.tools ?? undefined,
tool_choice: opts.tool_choice ?? undefined,
}

if (opts.stream) {
Expand All @@ -176,6 +179,7 @@ export default class JanProviderWeb extends AIEngine {
content: choice.message.content,
reasoning: choice.message.reasoning,
reasoning_content: choice.message.reasoning_content,
tool_calls: choice.message.tool_calls,
},
finish_reason: (choice.finish_reason || 'stop') as 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call',
})),
Expand Down Expand Up @@ -233,6 +237,7 @@ export default class JanProviderWeb extends AIEngine {
content: choice.delta.content,
reasoning: choice.delta.reasoning,
reasoning_content: choice.delta.reasoning_content,
tool_calls: choice.delta.tool_calls,
},
finish_reason: choice.finish_reason,
})),
Expand Down Expand Up @@ -300,8 +305,9 @@ export default class JanProviderWeb extends AIEngine {
return Array.from(this.activeSessions.values()).map(session => session.model_id)
}

async isToolSupported(): Promise<boolean> {
// Tools are not yet supported
return false
async isToolSupported(modelId: string): Promise<boolean> {
// Jan models support tool calls via MCP
console.log(`Checking tool support for Jan model ${modelId}: supported`);
return true;
}
}
Loading
Loading