feat(mcp): add React manager extension

This commit is contained in:
Darkatse 2026-08-08 02:17:36 -04:00
parent 6e69c5c6ba
commit e01fce4691
20 changed files with 5043 additions and 23 deletions

18
eslint.config.js Normal file
View file

@ -0,0 +1,18 @@
import jsxA11y from 'eslint-plugin-jsx-a11y';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['src/scripts/extensions/**/*.{ts,tsx}'],
extends: [
...tseslint.configs.recommendedTypeChecked,
reactHooks.configs.flat.recommended,
jsxA11y.flatConfigs.recommended,
],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
});

View file

@ -9,16 +9,18 @@
"ios": "node scripts/tauri-app.mjs --prepare-frontend ios",
"build": "pnpm run tauri:build",
"dev": "pnpm run tauri:dev",
"check": "pnpm run check:frontend && pnpm run check:types && pnpm run check:logging-boundaries && pnpm run check:rust-boundaries && pnpm test && pnpm run check:rust:clippy",
"check": "pnpm run check:frontend && pnpm run check:types && pnpm run check:lint && pnpm run check:logging-boundaries && pnpm run check:rust-boundaries && pnpm test && pnpm run check:rust:clippy",
"check:frontend": "node scripts/check-frontend-guardrails.mjs",
"check:frontend:baseline": "node scripts/check-frontend-guardrails.mjs --update-baseline",
"check:types": "tsc -p tsconfig.host.json",
"check:types": "tsc -p tsconfig.host.json && tsc -p tsconfig.ui.json",
"check:lint": "eslint \"src/scripts/extensions/**/*.{ts,tsx}\"",
"check:logging-boundaries": "node scripts/check-logging-boundaries.mjs",
"check:rust-boundaries": "node scripts/check-rust-crate-boundaries.mjs",
"check:rust:dev": "cargo check --manifest-path src-tauri/Cargo.toml --workspace --no-default-features",
"check:rust:clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings",
"test": "pnpm run test:contracts && pnpm run test:rust",
"test": "pnpm run test:contracts && pnpm run test:ui && pnpm run test:rust",
"test:contracts": "pnpm run web:build && node --test --test-concurrency=1 \"tests/**/*.test.mjs\"",
"test:ui": "rstest --config rstest.config.mjs",
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml --workspace --no-default-features",
"web:build": "rspack build --config rspack.config.js",
"web:dev": "rspack build --config rspack.config.js --watch",
@ -42,17 +44,28 @@
"devDependencies": {
"@rspack/cli": "^2.0.8",
"@rspack/core": "^2.0.8",
"@rstest/core": "^0.11.6",
"@tauri-apps/cli": "^2.10.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@types/jquery": "^3.5.33",
"@types/jquery-cropper": "^1.0.4",
"@types/jquery.transit": "^0.9.33",
"@types/jqueryui": "^1.12.24",
"@types/lodash": "^4.17.24",
"@types/node": "^20.19.43",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/select2": "^4.0.63",
"@types/toastr": "^2.1.43",
"es-module-lexer": "^2.0.0",
"typescript": "^5.9.3"
"eslint": "^9.39.5",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"happy-dom": "^20.11.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.66.0"
},
"dependencies": {
"@adobe/css-tools": "^4.4.4",
@ -75,6 +88,8 @@
"lodash": "^4.18.1",
"moment": "^2.30.1",
"morphdom": "^2.7.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"seedrandom": "^3.0.5",
"showdown": "^2.1.0",
"vue": "^3.5.33",

2853
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -25,22 +25,27 @@ const libraryCacheInputs = [
const agentSystemCacheInputs = [
...commonCacheInputs,
...listJavaScriptFiles('src/scripts/extensions/agent-system/src'),
...listJavaScriptFiles('src/scripts/tauritavern/agent'),
...listScriptFiles('src/scripts/extensions/agent-system/src'),
...listScriptFiles('src/scripts/tauritavern/agent'),
];
const mcpManagerCacheInputs = [
...commonCacheInputs,
...listScriptFiles('src/scripts/extensions/mcp-manager/src'),
];
const tauriSettingUiCacheInputs = [
...commonCacheInputs,
...listJavaScriptFiles('src/scripts/tauri/setting/settings-app'),
...listJavaScriptFiles('src/scripts/tauri/setting/dev-logs-app'),
...listJavaScriptFiles('src/scripts/tauri/setting/sync-app'),
...listScriptFiles('src/scripts/tauri/setting/settings-app'),
...listScriptFiles('src/scripts/tauri/setting/dev-logs-app'),
...listScriptFiles('src/scripts/tauri/setting/sync-app'),
];
function resolveRepoPath(file) {
return path.resolve(__dirname, file);
}
function listJavaScriptFiles(relativeDir) {
function listScriptFiles(relativeDir) {
const root = resolveRepoPath(relativeDir);
const results = [];
const stack = [root];
@ -55,7 +60,7 @@ function listJavaScriptFiles(relativeDir) {
continue;
}
if (entry.isFile() && path.extname(entry.name) === '.js') {
if (entry.isFile() && ['.js', '.ts', '.tsx'].includes(path.extname(entry.name))) {
results.push(path.relative(__dirname, fullPath).replace(/\\/g, '/'));
}
}
@ -94,7 +99,7 @@ function createPersistentCache(name, inputFiles) {
}
const sharedResolve = {
extensions: ['.js'],
extensions: ['.tsx', '.ts', '.js'],
alias: {
'/lib.js': path.resolve(__dirname, 'src/lib.js'),
'/script.js': path.resolve(__dirname, 'src/script.js'),
@ -152,6 +157,31 @@ function createVueDefinePlugin() {
});
}
function createReactModule() {
return {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
type: 'javascript/auto',
use: {
loader: 'builtin:swc-loader',
options: {
detectSyntax: 'auto',
jsc: {
transform: {
react: {
runtime: 'automatic',
},
},
},
},
},
},
],
};
}
const coreConfig = {
name: 'vendor-libs',
mode: 'production',
@ -205,6 +235,31 @@ const agentSystemConfig = {
],
};
const mcpManagerConfig = {
name: 'mcp-manager',
mode: 'production',
bail: true,
target: ['web', 'es2020'],
cache: createPersistentCache('mcp-manager', mcpManagerCacheInputs),
entry: {
index: './src/scripts/extensions/mcp-manager/src/index.tsx',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'src/scripts/extensions/mcp-manager/dist'),
module: true,
library: {
type: 'module'
},
clean: true,
},
module: createReactModule(),
resolve: sharedResolve,
optimization: sharedOptimization,
performance: sharedPerformance,
stats: sharedStats,
};
const tauriTavernSettingsConfig = {
name: 'tauritavern-settings',
dependencies: ['vendor-libs'],
@ -235,4 +290,4 @@ const tauriTavernSettingsConfig = {
],
};
export default [coreConfig, agentSystemConfig, tauriTavernSettingsConfig];
export default [coreConfig, agentSystemConfig, mcpManagerConfig, tauriTavernSettingsConfig];

17
rstest.config.mjs Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from '@rstest/core';
export default defineConfig({
include: ['src/scripts/extensions/**/*.test.tsx'],
testEnvironment: 'happy-dom',
tools: {
swc: {
jsc: {
transform: {
react: {
runtime: 'automatic',
},
},
},
},
},
});

View file

@ -50,6 +50,7 @@ const ENABLED_SYSTEM_EXTENSIONS: &[&str] = &[
"vectors",
"tauritavern-version",
"agent-system",
"mcp-manager",
"translate",
"tts",
];

View file

@ -67,6 +67,29 @@ fn test_http_clients() -> Arc<HttpClientPool> {
Arc::new(HttpClientPool::new(TEST_USER_AGENT))
}
#[tokio::test]
async fn discovery_includes_the_first_party_mcp_manager() {
let (root, user_extensions_dir, global_extensions_dir, source_store_root) = setup_paths().await;
let repository = FileExtensionRepository::new(
user_extensions_dir,
global_extensions_dir,
source_store_root,
test_http_clients(),
);
let extensions = repository
.discover_extensions()
.await
.expect("discover extensions");
assert!(
extensions
.iter()
.any(|extension| extension.name == "mcp-manager")
);
fs::remove_dir_all(root).await.expect("cleanup temp root");
}
#[tokio::test]
async fn embedded_install_version_and_update_round_trip_over_smart_http() {
let (root, user_extensions_dir, global_extensions_dir, source_store_root) = setup_paths().await;

View file

@ -0,0 +1,48 @@
{
"mcp_manager.active": "已启用",
"mcp_manager.activate": "启用",
"mcp_manager.activateHttpNote": "该端点使用未加密的 HTTP。同一网络中的其他设备可能观察或修改 MCP 流量。启用服务器不会授予任何工具权限。",
"mcp_manager.activateNote": "启用后可以向这个确切端点手动发起发现请求,但不会授予任何工具权限。",
"mcp_manager.activateTitle": "启用这个 MCP 服务器?",
"mcp_manager.addServer": "添加服务器",
"mcp_manager.addServerTitle": "添加 MCP 服务器",
"mcp_manager.adding": "正在添加…",
"mcp_manager.cancel": "取消",
"mcp_manager.configuredToolsMissing": "本次发现未提供的工具",
"mcp_manager.diagnostics": "发现说明",
"mcp_manager.discoverTools": "发现工具",
"mcp_manager.discovering": "正在发现工具…",
"mcp_manager.discoveryIdentity": "{implementation} · MCP {protocol}",
"mcp_manager.displayName": "名称",
"mcp_manager.displayNamePlaceholder": "本地工具",
"mcp_manager.emptyHint": "添加 Streamable HTTP 端点,查看它提供的工具。",
"mcp_manager.emptyTitle": "还没有 MCP 服务器",
"mcp_manager.endpoint": "端点",
"mcp_manager.endpointHint": "仅支持 Streamable HTTP。端点是注册身份的一部分,创建后不可更改。",
"mcp_manager.endpointInvalid": "请输入有效的 http:// 或 https:// 地址。",
"mcp_manager.hostApiUnavailable": "TauriTavern MCP Host API 不可用",
"mcp_manager.mcp": "MCP",
"mcp_manager.nameRequired": "请输入名称。",
"mcp_manager.newServerNote": "新服务器默认暂停,发现的每个工具默认关闭。",
"mcp_manager.paused": "已暂停",
"mcp_manager.pausedHint": "已暂停——启用后即可发现该服务器的工具。",
"mcp_manager.permissionAllow": "允许",
"mcp_manager.permissionAsk": "询问",
"mcp_manager.permissionFor": "{name} 的权限",
"mcp_manager.permissionOff": "关闭",
"mcp_manager.popupUnavailable": "SillyTavern Popup API 不可用",
"mcp_manager.refreshTools": "刷新工具",
"mcp_manager.remove": "移除",
"mcp_manager.removeNote": "该注册及其保存的全部工具权限都会被移除。",
"mcp_manager.removeTitle": "移除这个 MCP 服务器?",
"mcp_manager.rename": "重命名",
"mcp_manager.renameTitle": "重命名服务器",
"mcp_manager.retry": "重试",
"mcp_manager.schemaDetails": "Schema 与提示",
"mcp_manager.serverCount": "服务器 · {count}",
"mcp_manager.setOff": "设为关闭",
"mcp_manager.storageIssues": "需要处理的注册文件",
"mcp_manager.toolCount": "{count} 个工具",
"mcp_manager.toggleTools": "显示或隐藏工具",
"mcp_manager.unknownError": "未知错误"
}

View file

@ -0,0 +1,48 @@
{
"mcp_manager.active": "已啟用",
"mcp_manager.activate": "啟用",
"mcp_manager.activateHttpNote": "此端點使用未加密的 HTTP。同一網路中的其他裝置可能觀察或修改 MCP 流量。啟用伺服器不會授予任何工具權限。",
"mcp_manager.activateNote": "啟用後可以向這個確切端點手動發起探索請求,但不會授予任何工具權限。",
"mcp_manager.activateTitle": "啟用這個 MCP 伺服器?",
"mcp_manager.addServer": "新增伺服器",
"mcp_manager.addServerTitle": "新增 MCP 伺服器",
"mcp_manager.adding": "正在新增…",
"mcp_manager.cancel": "取消",
"mcp_manager.configuredToolsMissing": "本次探索未提供的工具",
"mcp_manager.diagnostics": "探索說明",
"mcp_manager.discoverTools": "探索工具",
"mcp_manager.discovering": "正在探索工具…",
"mcp_manager.discoveryIdentity": "{implementation} · MCP {protocol}",
"mcp_manager.displayName": "名稱",
"mcp_manager.displayNamePlaceholder": "本機工具",
"mcp_manager.emptyHint": "新增 Streamable HTTP 端點,查看它提供的工具。",
"mcp_manager.emptyTitle": "還沒有 MCP 伺服器",
"mcp_manager.endpoint": "端點",
"mcp_manager.endpointHint": "僅支援 Streamable HTTP。端點是註冊身份的一部分,建立後不可更改。",
"mcp_manager.endpointInvalid": "請輸入有效的 http:// 或 https:// 位址。",
"mcp_manager.hostApiUnavailable": "TauriTavern MCP Host API 不可用",
"mcp_manager.mcp": "MCP",
"mcp_manager.nameRequired": "請輸入名稱。",
"mcp_manager.newServerNote": "新伺服器預設暫停,探索到的每個工具預設關閉。",
"mcp_manager.paused": "已暫停",
"mcp_manager.pausedHint": "已暫停——啟用後即可探索此伺服器的工具。",
"mcp_manager.permissionAllow": "允許",
"mcp_manager.permissionAsk": "詢問",
"mcp_manager.permissionFor": "{name} 的權限",
"mcp_manager.permissionOff": "關閉",
"mcp_manager.popupUnavailable": "SillyTavern Popup API 不可用",
"mcp_manager.refreshTools": "重新整理工具",
"mcp_manager.remove": "移除",
"mcp_manager.removeNote": "該註冊及其儲存的全部工具權限都會被移除。",
"mcp_manager.removeTitle": "移除這個 MCP 伺服器?",
"mcp_manager.rename": "重新命名",
"mcp_manager.renameTitle": "重新命名伺服器",
"mcp_manager.retry": "重試",
"mcp_manager.schemaDetails": "Schema 與提示",
"mcp_manager.serverCount": "伺服器 · {count}",
"mcp_manager.setOff": "設為關閉",
"mcp_manager.storageIssues": "需要處理的註冊檔案",
"mcp_manager.toolCount": "{count} 個工具",
"mcp_manager.toggleTools": "顯示或隱藏工具",
"mcp_manager.unknownError": "未知錯誤"
}

View file

@ -0,0 +1,16 @@
{
"display_name": "MCP",
"loading_order": 26,
"requires": [],
"optional": [],
"js": "dist/index.bundle.js",
"css": "style.css",
"i18n": {
"zh-cn": "i18n/zh-cn.json",
"zh-tw": "i18n/zh-tw.json"
},
"author": "TauriTavern",
"version": "0.1.0",
"description": "First-party MCP server registration, discovery, and permission manager.",
"homePage": "https://github.com/Darkatse/TauriTavern"
}

View file

@ -0,0 +1,283 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test } from '@rstest/core';
import userEvent from '@testing-library/user-event';
import {
McpManagerApp,
type McpManagerActions,
type McpManagerInitial,
} from './McpManagerApp';
import { openAddServerDialog, tr } from './host';
const SERVER_ID = '11111111-1111-4111-8111-111111111111';
function server(state: TauriTavernMcpServerState = 'paused'): TauriTavernMcpServer {
return {
id: SERVER_ID,
displayName: 'Local tools',
endpoint: 'http://127.0.0.1:3000/mcp',
state,
toolPermissions: {},
};
}
function discovery(): TauriTavernMcpDiscoveryResult {
return {
registrationId: SERVER_ID,
protocolVersion: '2026-07-28',
serverName: 'Local MCP',
tools: [{
id: `mcp/${SERVER_ID}:search`,
nativeName: 'search',
title: 'Search files',
description: 'Search local files by name.',
inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
annotations: {},
permission: 'off',
}],
diagnostics: [],
staleTools: [],
};
}
function unexpected(name: string): Promise<never> {
return Promise.reject(new Error(`Unexpected MCP Manager action: ${name}`));
}
function actions(overrides: Partial<McpManagerActions> = {}): McpManagerActions {
return {
addServer: () => unexpected('addServer'),
renameServer: () => unexpected('renameServer'),
setState: () => unexpected('setState'),
remove: () => unexpected('remove'),
discover: () => unexpected('discover'),
setPermission: () => unexpected('setPermission'),
confirmActivate: () => Promise.resolve(true),
confirmRemove: () => Promise.resolve(true),
...overrides,
};
}
function initial(servers: TauriTavernMcpServer[] = []): McpManagerInitial {
return { servers, storageIssues: [] };
}
type TestPopupOptions = {
onOpen?: () => void;
onClosing?: (popup: TestPopup) => Promise<boolean>;
};
class TestPopup {
static current: TestPopup | undefined;
result: unknown;
private resolve: ((value: unknown) => void) | undefined;
constructor(
readonly content: Element,
type: number,
inputValue: string,
private readonly options: TestPopupOptions,
) {
void type;
void inputValue;
TestPopup.current = this;
}
show(): Promise<unknown> {
document.body.append(this.content);
this.options.onOpen?.();
return new Promise(resolve => {
this.resolve = resolve;
});
}
async close(result: unknown): Promise<boolean> {
this.result = result;
if (this.options.onClosing && !await this.options.onClosing(this)) {
return false;
}
this.content.remove();
this.resolve?.(result);
return true;
}
}
function installPopupHost(): void {
Object.defineProperty(window, 'SillyTavern', {
configurable: true,
value: {
getContext: () => ({
Popup: TestPopup,
POPUP_TYPE: { CONFIRM: 1, INPUT: 2 },
POPUP_RESULT: { AFFIRMATIVE: 1 },
}),
},
});
}
afterEach(() => {
cleanup();
TestPopup.current?.content.remove();
TestPopup.current = undefined;
Reflect.deleteProperty(window, 'SillyTavern');
});
test('adds a server through the dialog action and lists it paused', async () => {
const created = server('paused');
const user = userEvent.setup();
render(
<McpManagerApp
initial={initial()}
tr={tr}
actions={actions({ addServer: () => Promise.resolve(created) })}
/>,
);
await user.click(screen.getByRole('button', { name: 'Add server' }));
expect(await screen.findByText('Local tools')).toBeTruthy();
expect(screen.getByText('http://127.0.0.1:3000/mcp')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Paused' })).toBeTruthy();
});
test('discovers tools on expand and persists an explicit permission choice', async () => {
const permissionCalls: Array<{
registrationId: string;
nativeName: string;
permission: TauriTavernMcpToolPermission;
}> = [];
let discoverCalls = 0;
const activeServer = server('active');
const user = userEvent.setup();
render(
<McpManagerApp
initial={initial([activeServer])}
tr={tr}
actions={actions({
discover: () => {
discoverCalls += 1;
return Promise.resolve(discovery());
},
setPermission: input => {
permissionCalls.push(input);
return Promise.resolve({
...activeServer,
toolPermissions: { [input.nativeName]: 'allow' },
});
},
})}
/>,
);
await user.click(screen.getByRole('button', { name: 'Show or hide tools' }));
expect(await screen.findByText('Search files')).toBeTruthy();
expect(discoverCalls).toBe(1);
await user.click(screen.getByRole('radio', { name: 'Allow' }));
await waitFor(() => expect(permissionCalls).toEqual([{
registrationId: SERVER_ID,
nativeName: 'search',
permission: 'allow',
}]));
expect(screen.getByRole<HTMLInputElement>('radio', { name: 'Allow' }).checked).toBe(true);
});
test('does not discover while paused and explains the state instead', async () => {
let discoverCalls = 0;
const user = userEvent.setup();
render(
<McpManagerApp
initial={initial([server('paused')])}
tr={tr}
actions={actions({
discover: () => {
discoverCalls += 1;
return Promise.resolve(discovery());
},
})}
/>,
);
await user.click(screen.getByRole('button', { name: 'Show or hide tools' }));
expect(await screen.findByText(/Paused — activate to discover/)).toBeTruthy();
expect(discoverCalls).toBe(0);
});
test('keeps a discovery error while an unrelated action is cancelled', async () => {
let resolveRename!: (value: TauriTavernMcpServer | null) => void;
const renameResult = new Promise<TauriTavernMcpServer | null>(resolve => {
resolveRename = resolve;
});
const user = userEvent.setup();
render(
<McpManagerApp
initial={initial([server('active')])}
tr={tr}
actions={actions({
discover: () => Promise.reject(new Error('discovery failed')),
renameServer: () => renameResult,
})}
/>,
);
await user.click(screen.getByRole('button', { name: 'Show or hide tools' }));
expect((await screen.findByRole('alert')).textContent).toBe('discovery failed');
await user.click(screen.getByRole('button', { name: 'Rename' }));
expect(screen.queryByText('Discovering tools…')).toBeNull();
expect(screen.getByRole('alert').textContent).toBe('discovery failed');
await act(async () => {
resolveRename(null);
await renameResult;
});
expect(screen.getByRole('alert').textContent).toBe('discovery failed');
});
test('add-server popup validates in place, preserves failures, and returns the created server', async () => {
installPopupHost();
const drafts: Array<{ displayName: string; endpoint: string }> = [];
const opened = openAddServerDialog(draft => {
drafts.push(draft);
if (drafts.length === 1) {
return Promise.reject(new Error('endpoint host is not allowed'));
}
return Promise.resolve(server());
});
const popup = TestPopup.current;
if (!popup) {
throw new Error('Add-server popup was not created');
}
const name = screen.getByLabelText<HTMLInputElement>('Name');
const endpoint = screen.getByLabelText<HTMLInputElement>('Endpoint');
expect(document.activeElement).toBe(name);
expect(await popup.close(1)).toBe(false);
expect(screen.getByRole('alert').textContent).toBe('Enter a name.');
expect(drafts).toEqual([]);
const user = userEvent.setup();
await user.type(name, ' Local tools ');
await user.type(endpoint, 'file:///etc/passwd');
expect(await popup.close(1)).toBe(false);
expect(screen.getByRole('alert').textContent).toBe('Enter a valid http:// or https:// URL.');
expect(drafts).toEqual([]);
await user.clear(endpoint);
await user.type(endpoint, ' http://127.0.0.1:3000/mcp ');
expect(await popup.close(1)).toBe(false);
expect(screen.getByRole('alert').textContent).toBe('endpoint host is not allowed');
expect(name.value).toBe(' Local tools ');
expect(endpoint.value).toBe(' http://127.0.0.1:3000/mcp ');
expect(await popup.close(1)).toBe(true);
expect(await opened).toEqual(server());
expect(drafts).toEqual([
{ displayName: 'Local tools', endpoint: 'http://127.0.0.1:3000/mcp' },
{ displayName: 'Local tools', endpoint: 'http://127.0.0.1:3000/mcp' },
]);
});

View file

@ -0,0 +1,288 @@
import { useState } from 'react';
import { errorText, type McpTranslator } from './host';
import { ServerRow } from './ServerRow';
export type McpManagerInitial = Awaited<ReturnType<TauriTavernMcpApi['servers']['list']>>;
export type McpManagerActions = {
/** Opens the Add-server dialog; resolves the created server, or null when cancelled. */
addServer: () => Promise<TauriTavernMcpServer | null>;
/** Prompts for a new name and renames; resolves the updated server, or null when unchanged. */
renameServer: (server: TauriTavernMcpServer) => Promise<TauriTavernMcpServer | null>;
setState: TauriTavernMcpApi['servers']['setState'];
remove: TauriTavernMcpApi['servers']['remove'];
discover: TauriTavernMcpApi['servers']['discover'];
setPermission: TauriTavernMcpApi['tools']['setPermission'];
confirmActivate: (server: TauriTavernMcpServer) => Promise<boolean>;
confirmRemove: (server: TauriTavernMcpServer) => Promise<boolean>;
};
type McpManagerAppProps = {
initial: McpManagerInitial;
actions: McpManagerActions;
tr: McpTranslator;
};
type ServerStatus = {
activity: 'discover' | 'mutate' | undefined;
error: string;
};
export function McpManagerApp({ initial, actions, tr }: McpManagerAppProps) {
const [servers, setServers] = useState(initial.servers);
const [discoveries, setDiscoveries] = useState<Record<string, TauriTavernMcpDiscoveryResult>>({});
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [statuses, setStatuses] = useState<Record<string, ServerStatus>>({});
const [adding, setAdding] = useState(false);
const [panelError, setPanelError] = useState('');
const sortedServers = [...servers].sort((left, right) => (
left.displayName.localeCompare(right.displayName) || left.id.localeCompare(right.id)
));
function updateStatus(id: string, patch: Partial<ServerStatus>): void {
setStatuses(current => ({
...current,
[id]: { activity: undefined, error: '', ...current[id], ...patch },
}));
}
function replaceServer(updated: TauriTavernMcpServer): void {
setServers(current => current.map(server => (server.id === updated.id ? updated : server)));
}
async function runServerAction(
id: string,
activity: NonNullable<ServerStatus['activity']>,
operation: () => Promise<boolean>,
): Promise<void> {
updateStatus(id, activity === 'discover' ? { activity, error: '' } : { activity });
try {
if (await operation()) {
updateStatus(id, { error: '' });
}
} catch (error) {
updateStatus(id, { error: errorText(error, tr('unknownError')) });
} finally {
updateStatus(id, { activity: undefined });
}
}
async function addServer(): Promise<void> {
setPanelError('');
setAdding(true);
try {
const server = await actions.addServer();
if (server) {
setServers(current => [...current, server]);
}
} catch (error) {
setPanelError(errorText(error, tr('unknownError')));
} finally {
setAdding(false);
}
}
async function renameServer(server: TauriTavernMcpServer): Promise<void> {
await runServerAction(server.id, 'mutate', async () => {
const updated = await actions.renameServer(server);
if (!updated) {
return false;
}
replaceServer(updated);
return true;
});
}
async function toggleServer(server: TauriTavernMcpServer): Promise<void> {
const nextState: TauriTavernMcpServerState = server.state === 'active' ? 'paused' : 'active';
await runServerAction(server.id, 'mutate', async () => {
if (nextState === 'active' && !await actions.confirmActivate(server)) {
return false;
}
replaceServer(await actions.setState({ registrationId: server.id, state: nextState }));
return true;
});
}
async function removeServer(server: TauriTavernMcpServer): Promise<void> {
await runServerAction(server.id, 'mutate', async () => {
if (!await actions.confirmRemove(server)) {
return false;
}
await actions.remove(server.id);
setServers(current => current.filter(item => item.id !== server.id));
setDiscoveries(current => {
const next = { ...current };
delete next[server.id];
return next;
});
return true;
});
}
async function discover(server: TauriTavernMcpServer): Promise<void> {
await runServerAction(server.id, 'discover', async () => {
const discovery = await actions.discover(server.id);
setDiscoveries(current => ({ ...current, [server.id]: discovery }));
return true;
});
}
function toggleExpand(server: TauriTavernMcpServer): void {
const next = !expanded[server.id];
setExpanded(current => ({ ...current, [server.id]: next }));
// Expanding an active server means "show me its tools" — discover right away.
// A previous failure stays visible instead of refiring on every toggle.
const status = statuses[server.id];
if (next && server.state === 'active' && !discoveries[server.id] && !status?.error && !status?.activity) {
void discover(server);
}
}
async function setPermission(
server: TauriTavernMcpServer,
tool: TauriTavernMcpTool,
permission: TauriTavernMcpToolPermission,
): Promise<void> {
await runServerAction(server.id, 'mutate', async () => {
replaceServer(await actions.setPermission({
registrationId: server.id,
nativeName: tool.nativeName,
permission,
}));
setDiscoveries(current => {
const discovery = current[server.id];
if (!discovery) {
return current;
}
return {
...current,
[server.id]: {
...discovery,
tools: discovery.tools.map(item => (
item.id === tool.id ? { ...item, permission } : item
)),
},
};
});
return true;
});
}
async function clearStalePermission(server: TauriTavernMcpServer, nativeName: string): Promise<void> {
await runServerAction(server.id, 'mutate', async () => {
replaceServer(await actions.setPermission({
registrationId: server.id,
nativeName,
permission: 'off',
}));
setDiscoveries(current => {
const discovery = current[server.id];
if (!discovery) {
return current;
}
return {
...current,
[server.id]: {
...discovery,
staleTools: discovery.staleTools.filter(tool => tool.nativeName !== nativeName),
},
};
});
return true;
});
}
return (
<section id="mcp_manager_settings" className="tt-mcp-root">
<div className="inline-drawer">
<div className="inline-drawer-toggle inline-drawer-header tt-mcp-drawer-header">
<span className="tt-mcp-title">
<i className="fa-solid fa-plug-circle-bolt" aria-hidden="true" />
<b>{tr('mcp')}</b>
</span>
<div className="inline-drawer-icon fa-solid fa-circle-chevron-down down" />
</div>
<div className="inline-drawer-content">
{initial.storageIssues.length > 0 && (
<section className="tt-mcp-alert" role="status">
<b>
<i className="fa-solid fa-triangle-exclamation" aria-hidden="true" />
{tr('storageIssues')}
</b>
<ul>
{initial.storageIssues.map(issue => (
<li key={issue.fileName}>
<code>{issue.fileName}</code>
<span>{issue.message}</span>
</li>
))}
</ul>
</section>
)}
{panelError && <p className="tt-mcp-error" role="alert">{panelError}</p>}
{servers.length === 0 ? (
<div className="tt-mcp-empty">
<i className="fa-solid fa-plug-circle-bolt" aria-hidden="true" />
<b>{tr('emptyTitle')}</b>
<span>{tr('emptyHint')}</span>
<button
type="button"
className="menu_button menu_button_icon"
disabled={adding}
onClick={() => void addServer()}
>
<i className={`fa-solid ${adding ? 'fa-circle-notch fa-spin' : 'fa-plus'}`} aria-hidden="true" />
<span>{tr('addServer')}</span>
</button>
</div>
) : (
<>
<div className="tt-mcp-toolbar">
<span className="tt-mcp-count">{tr('serverCount', { count: servers.length })}</span>
<button
type="button"
className="menu_button menu_button_icon"
disabled={adding}
onClick={() => void addServer()}
>
<i className={`fa-solid ${adding ? 'fa-circle-notch fa-spin' : 'fa-plus'}`} aria-hidden="true" />
<span>{tr('addServer')}</span>
</button>
</div>
<div className="tt-mcp-servers">
{sortedServers.map(server => {
const status = statuses[server.id];
return (
<ServerRow
key={server.id}
server={server}
discovery={discoveries[server.id]}
expanded={expanded[server.id] === true}
busy={status?.activity !== undefined}
discovering={status?.activity === 'discover'}
error={status?.error ?? ''}
tr={tr}
onToggleExpand={() => toggleExpand(server)}
onToggleState={() => void toggleServer(server)}
onRename={() => void renameServer(server)}
onRemove={() => void removeServer(server)}
onDiscover={() => void discover(server)}
onSetPermission={(tool, permission) => void setPermission(server, tool, permission)}
onClearStale={nativeName => void clearStalePermission(server, nativeName)}
/>
);
})}
</div>
</>
)}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,194 @@
import type { McpTranslator } from './host';
import { ToolItem } from './ToolItem';
function discoveryIdentity(discovery: TauriTavernMcpDiscoveryResult, tr: McpTranslator): string {
const implementation = [discovery.serverName, discovery.serverVersion].filter(Boolean).join(' ');
return implementation
? tr('discoveryIdentity', { implementation, protocol: discovery.protocolVersion })
: `MCP ${discovery.protocolVersion}`;
}
type ServerRowProps = {
server: TauriTavernMcpServer;
discovery: TauriTavernMcpDiscoveryResult | undefined;
expanded: boolean;
busy: boolean;
discovering: boolean;
error: string;
tr: McpTranslator;
onToggleExpand: () => void;
onToggleState: () => void;
onRename: () => void;
onRemove: () => void;
onDiscover: () => void;
onSetPermission: (tool: TauriTavernMcpTool, permission: TauriTavernMcpToolPermission) => void;
onClearStale: (nativeName: string) => void;
};
export function ServerRow({
server,
discovery,
expanded,
busy,
discovering,
error,
tr,
onToggleExpand,
onToggleState,
onRename,
onRemove,
onDiscover,
onSetPermission,
onClearStale,
}: ServerRowProps) {
const active = server.state === 'active';
return (
<article className={`tt-mcp-server${active ? ' is-active' : ''}${expanded ? ' is-expanded' : ''}`}>
<div className="tt-mcp-server-row">
<span className="tt-mcp-lamp" aria-hidden="true" />
<div className="tt-mcp-identity">
<b className="tt-mcp-name" title={server.displayName}>{server.displayName}</b>
<code className="tt-mcp-endpoint" title={server.endpoint}>{server.endpoint}</code>
</div>
<button
type="button"
className={`tt-mcp-state${active ? ' is-active' : ''}`}
aria-pressed={active}
disabled={busy}
onClick={onToggleState}
>
<span className="tt-mcp-state-dot" aria-hidden="true" />
{tr(active ? 'active' : 'paused')}
</button>
<div className="tt-mcp-row-actions">
<button
type="button"
className="tt-mcp-icon-btn"
title={tr('rename')}
aria-label={tr('rename')}
disabled={busy}
onClick={onRename}
>
<i className="fa-solid fa-pen" aria-hidden="true" />
</button>
<button
type="button"
className="tt-mcp-icon-btn is-danger"
title={tr('remove')}
aria-label={tr('remove')}
disabled={busy}
onClick={onRemove}
>
<i className="fa-solid fa-trash-can" aria-hidden="true" />
</button>
<button
type="button"
className="tt-mcp-icon-btn tt-mcp-chevron"
title={tr('toggleTools')}
aria-label={tr('toggleTools')}
aria-expanded={expanded}
onClick={onToggleExpand}
>
<i className="fa-solid fa-chevron-down" aria-hidden="true" />
</button>
</div>
</div>
{error && !expanded && <p className="tt-mcp-error" role="alert">{error}</p>}
{expanded && (
<div className="tt-mcp-console">
{error && <p className="tt-mcp-error" role="alert">{error}</p>}
{!discovery && !active && (
<p className="tt-mcp-note">
<i className="fa-solid fa-pause" aria-hidden="true" />
{tr('pausedHint')}
</p>
)}
{!discovery && active && discovering && (
<p className="tt-mcp-note">
<i className="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
{tr('discovering')}
</p>
)}
{!discovery && active && !discovering && (
<button type="button" className="menu_button menu_button_icon" disabled={busy} onClick={onDiscover}>
<i className="fa-solid fa-arrows-rotate" aria-hidden="true" />
<span>{error ? tr('retry') : tr('discoverTools')}</span>
</button>
)}
{discovery && (
<>
<div className="tt-mcp-console-head">
<span className="tt-mcp-console-meta">{discoveryIdentity(discovery, tr)}</span>
<span className="tt-mcp-console-count">{tr('toolCount', { count: discovery.tools.length })}</span>
<button
type="button"
className="tt-mcp-icon-btn"
title={tr('refreshTools')}
aria-label={tr('refreshTools')}
disabled={!active || busy}
onClick={onDiscover}
>
<i className={`fa-solid ${discovering ? 'fa-circle-notch fa-spin' : 'fa-arrows-rotate'}`} aria-hidden="true" />
</button>
</div>
{discovery.diagnostics.length > 0 && (
<details className="tt-mcp-details">
<summary>{tr('diagnostics')} · {discovery.diagnostics.length}</summary>
<ul>
{discovery.diagnostics.map(diagnostic => (
<li key={`${diagnostic.code}:${diagnostic.nativeName ?? ''}:${diagnostic.message}`}>
<b>{diagnostic.nativeName ?? diagnostic.code}</b>
<span>{diagnostic.message}</span>
</li>
))}
</ul>
</details>
)}
<ul className="tt-mcp-tools">
{discovery.tools.map(tool => (
<ToolItem
key={tool.id}
tool={tool}
busy={busy}
tr={tr}
onSetPermission={permission => onSetPermission(tool, permission)}
/>
))}
</ul>
{discovery.staleTools.length > 0 && (
<div className="tt-mcp-stale">
<b>{tr('configuredToolsMissing')}</b>
<ul>
{discovery.staleTools.map(tool => (
<li key={tool.nativeName}>
<code>{tool.nativeName}</code>
<span>{tr(tool.permission === 'allow' ? 'permissionAllow' : 'permissionAsk')}</span>
<button
type="button"
className="menu_button"
disabled={busy}
onClick={() => onClearStale(tool.nativeName)}
>
{tr('setOff')}
</button>
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
)}
</article>
);
}

View file

@ -0,0 +1,116 @@
import type { McpMessageKey, McpTranslator } from './host';
const PERMISSIONS = ['off', 'ask', 'allow'] as const satisfies readonly TauriTavernMcpToolPermission[];
const PERMISSION_LABELS: Record<TauriTavernMcpToolPermission, McpMessageKey> = {
off: 'permissionOff',
ask: 'permissionAsk',
allow: 'permissionAllow',
};
type SchemaParam = {
name: string;
required: boolean;
description: string;
};
/**
* Reads the human-facing surface of a JSON Schema 2020-12 object schema:
* property names, whether they are required, and their one-line descriptions.
* Non-object shapes simply yield no params; the raw schema stays one click away.
*/
function extractSchemaParams(inputSchema: Record<string, unknown>): SchemaParam[] {
const { properties, required } = inputSchema;
if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
return [];
}
const requiredNames = new Set(
Array.isArray(required) ? required.filter((name): name is string => typeof name === 'string') : [],
);
return Object.entries(properties as Record<string, unknown>).map(([name, schema]) => {
const description = (
typeof schema === 'object'
&& schema !== null
&& 'description' in schema
&& typeof schema.description === 'string'
) ? schema.description : '';
return { name, required: requiredNames.has(name), description };
});
}
function toolDetails(tool: TauriTavernMcpTool): string {
return JSON.stringify({
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema,
annotations: tool.annotations,
}, null, 2);
}
type PermissionGroupProps = {
tool: TauriTavernMcpTool;
busy: boolean;
tr: McpTranslator;
onSetPermission: (permission: TauriTavernMcpToolPermission) => void;
};
function PermissionGroup({ tool, busy, tr, onSetPermission }: PermissionGroupProps) {
return (
<div className="tt-mcp-perm" role="radiogroup" aria-label={tr('permissionFor', { name: tool.nativeName })}>
{PERMISSIONS.map(permission => (
<label key={permission} className={tool.permission === permission ? 'is-selected' : ''}>
<input
type="radio"
name={`tt-mcp-perm-${tool.id}`}
value={permission}
checked={tool.permission === permission}
disabled={busy}
onChange={() => onSetPermission(permission)}
/>
<span>{tr(PERMISSION_LABELS[permission])}</span>
</label>
))}
</div>
);
}
type ToolItemProps = {
tool: TauriTavernMcpTool;
busy: boolean;
tr: McpTranslator;
onSetPermission: (permission: TauriTavernMcpToolPermission) => void;
};
export function ToolItem({ tool, busy, tr, onSetPermission }: ToolItemProps) {
const params = extractSchemaParams(tool.inputSchema);
const hasNativeAlias = tool.title !== undefined && tool.title !== tool.nativeName;
return (
<li className="tt-mcp-tool">
<div className="tt-mcp-tool-title">
<b>{tool.title ?? tool.nativeName}</b>
{hasNativeAlias && <code>{tool.nativeName}</code>}
</div>
<PermissionGroup tool={tool} busy={busy} tr={tr} onSetPermission={onSetPermission} />
<div className="tt-mcp-tool-body">
{tool.description && <p>{tool.description}</p>}
{params.length > 0 && (
<div className="tt-mcp-params">
{params.map(param => (
<code
key={param.name}
className={param.required ? 'is-required' : ''}
title={param.description || undefined}
>
{param.required ? `${param.name}*` : param.name}
</code>
))}
</div>
)}
<details className="tt-mcp-details">
<summary>{tr('schemaDetails')}</summary>
<pre>{toolDetails(tool)}</pre>
</details>
</div>
</li>
);
}

View file

@ -0,0 +1,354 @@
const PREFIX = 'mcp_manager.';
const DEFAULT_MESSAGES = Object.freeze({
active: 'Active',
activate: 'Activate',
activateHttpNote: 'This endpoint uses unencrypted HTTP. Other devices on the network may observe or modify MCP traffic. Activation does not grant tool permission.',
activateNote: 'Activation allows manual discovery requests to this exact endpoint. It does not grant tool permission.',
activateTitle: 'Activate this MCP server?',
addServer: 'Add server',
addServerTitle: 'Add MCP server',
adding: 'Adding…',
cancel: 'Cancel',
configuredToolsMissing: 'Not offered by this discovery',
diagnostics: 'Discovery notes',
discoverTools: 'Discover tools',
discovering: 'Discovering tools…',
discoveryIdentity: '{implementation} · MCP {protocol}',
displayName: 'Name',
displayNamePlaceholder: 'Local tools',
emptyHint: 'Add a Streamable HTTP endpoint to see the tools it offers.',
emptyTitle: 'No MCP servers yet',
endpoint: 'Endpoint',
endpointHint: 'Streamable HTTP. This endpoint is the registration identity and cannot be changed later.',
endpointInvalid: 'Enter a valid http:// or https:// URL.',
hostApiUnavailable: 'TauriTavern MCP Host API is unavailable',
mcp: 'MCP',
nameRequired: 'Enter a name.',
newServerNote: 'New servers start paused, and every discovered tool starts Off.',
paused: 'Paused',
pausedHint: 'Paused — activate to discover this server\'s tools.',
permissionAllow: 'Allow',
permissionAsk: 'Ask',
permissionFor: 'Permission for {name}',
permissionOff: 'Off',
popupUnavailable: 'SillyTavern Popup API is unavailable',
refreshTools: 'Refresh tools',
remove: 'Remove',
removeNote: 'The registration and all saved tool permissions will be removed.',
removeTitle: 'Remove this MCP server?',
rename: 'Rename',
renameTitle: 'Rename server',
retry: 'Retry',
schemaDetails: 'Schema & hints',
serverCount: 'Servers · {count}',
setOff: 'Set Off',
storageIssues: 'Registration files needing attention',
toolCount: '{count} tools',
toggleTools: 'Show or hide tools',
unknownError: 'Unknown error',
});
export type McpMessageKey = keyof typeof DEFAULT_MESSAGES;
export type McpMessageParams = Readonly<Record<string, string | number>>;
export type McpTranslator = (key: McpMessageKey, params?: McpMessageParams) => string;
type PopupInstance = {
result: unknown;
show: () => Promise<unknown>;
};
type PopupOptions = {
okButton?: string;
cancelButton?: string;
allowVerticalScrolling?: boolean;
onOpen?: () => void;
onClosing?: (popup: PopupInstance) => Promise<boolean>;
};
type PopupConstructor = new (
content: Element,
type: number,
inputValue: string,
options: PopupOptions,
) => PopupInstance;
type SillyTavernContext = {
translate?: (fallback: string, key?: string) => string;
Popup?: PopupConstructor;
POPUP_TYPE?: { CONFIRM: number; INPUT: number };
POPUP_RESULT?: { AFFIRMATIVE: unknown };
};
type SillyTavernWindow = Window & {
SillyTavern?: {
getContext?: () => SillyTavernContext;
};
};
function context(): SillyTavernContext | null {
return (window as SillyTavernWindow).SillyTavern?.getContext?.() ?? null;
}
function requirePopupRuntime(): { Popup: PopupConstructor; affirmative: unknown } {
const host = context();
const Popup = host?.Popup;
const affirmative = host?.POPUP_RESULT?.AFFIRMATIVE;
if (!Popup || affirmative === undefined) {
throw new Error(tr('popupUnavailable'));
}
return { Popup, affirmative };
}
function formatMessage(message: string, params: McpMessageParams): string {
return message.replace(/\{(\w+)\}/g, (match, name: string) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
));
}
export const tr: McpTranslator = (key, params = {}) => {
const fallback = DEFAULT_MESSAGES[key];
const message = context()?.translate?.(fallback, `${PREFIX}${key}`) ?? fallback;
return formatMessage(message, params);
};
export function errorText(error: unknown, fallback: string): string {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' && error ? error : fallback;
}
export async function waitForHostReady(): Promise<void> {
const ready = window.__TAURITAVERN__?.ready ?? window.__TAURITAVERN_MAIN_READY__;
if (ready !== undefined && ready !== null) {
await ready;
}
}
export function requireMcpApi(): TauriTavernMcpApi {
const api = window.__TAURITAVERN__?.api?.mcp;
if (!api) {
throw new Error(tr('hostApiUnavailable'));
}
return api;
}
function confirmationContent(title: string, endpoint: string, note: string): HTMLElement {
const content = document.createElement('div');
content.className = 'tt-mcp-confirm';
const heading = document.createElement('h3');
heading.textContent = title;
const endpointCode = document.createElement('code');
endpointCode.textContent = endpoint;
const detail = document.createElement('p');
detail.textContent = note;
content.append(heading, endpointCode, detail);
return content;
}
async function confirm(content: HTMLElement, okButton: string): Promise<boolean> {
const { Popup, affirmative } = requirePopupRuntime();
const confirmType = context()?.POPUP_TYPE?.CONFIRM;
if (confirmType === undefined) {
throw new Error(tr('popupUnavailable'));
}
const popup = new Popup(content, confirmType, '', {
okButton,
cancelButton: tr('cancel'),
allowVerticalScrolling: true,
});
return await popup.show() === affirmative;
}
export async function confirmActivate(server: TauriTavernMcpServer): Promise<boolean> {
const insecureHttp = new URL(server.endpoint).protocol === 'http:';
return confirm(
confirmationContent(
tr('activateTitle'),
server.endpoint,
tr(insecureHttp ? 'activateHttpNote' : 'activateNote'),
),
tr('activate'),
);
}
export async function confirmRemove(server: TauriTavernMcpServer): Promise<boolean> {
return confirm(
confirmationContent(tr('removeTitle'), server.endpoint, tr('removeNote')),
tr('remove'),
);
}
/**
* Vanilla SillyTavern rename flow: an INPUT popup resolves the typed string on
* OK, `false` on the negative button and `null` on cancel/Escape.
* Returns null when the user cancelled or the name did not change.
*/
export async function promptServerName(server: TauriTavernMcpServer): Promise<string | null> {
const { Popup } = requirePopupRuntime();
const inputType = context()?.POPUP_TYPE?.INPUT;
if (inputType === undefined) {
throw new Error(tr('popupUnavailable'));
}
const content = document.createElement('h3');
content.textContent = tr('renameTitle');
const value = await new Popup(content, inputType, server.displayName, {
okButton: tr('rename'),
cancelButton: tr('cancel'),
}).show();
if (typeof value !== 'string') {
return null;
}
const displayName = value.trim();
return displayName.length > 0 && displayName !== server.displayName ? displayName : null;
}
function addServerInput(
form: HTMLElement,
id: string,
labelText: string,
placeholder: string,
): HTMLInputElement {
const label = document.createElement('label');
label.htmlFor = id;
label.textContent = labelText;
const input = document.createElement('input');
input.id = id;
input.className = 'text_pole result-control';
input.dataset.result = '1';
input.dataset.resultEvent = 'submit';
input.type = 'text';
input.placeholder = placeholder;
input.autocomplete = 'off';
form.append(label, input);
return input;
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
export async function openAddServerDialog(
create: TauriTavernMcpApi['servers']['create'],
): Promise<TauriTavernMcpServer | null> {
const { Popup, affirmative } = requirePopupRuntime();
const confirmType = context()?.POPUP_TYPE?.CONFIRM;
if (confirmType === undefined) {
throw new Error(tr('popupUnavailable'));
}
const form = document.createElement('div');
form.className = 'tt-mcp-add-form';
const heading = document.createElement('h3');
heading.textContent = tr('addServerTitle');
form.append(heading);
const displayName = addServerInput(
form,
'tt-mcp-add-name',
tr('displayName'),
tr('displayNamePlaceholder'),
);
displayName.maxLength = 128;
const endpoint = addServerInput(
form,
'tt-mcp-add-endpoint',
tr('endpoint'),
'http://127.0.0.1:3000/mcp',
);
endpoint.inputMode = 'url';
for (const text of [tr('endpointHint'), tr('newServerNote')]) {
const hint = document.createElement('small');
hint.className = 'tt-mcp-add-hint';
hint.textContent = text;
form.append(hint);
}
const error = document.createElement('p');
error.className = 'tt-mcp-error';
error.setAttribute('role', 'alert');
const busyNote = document.createElement('p');
busyNote.className = 'tt-mcp-note';
const spinner = document.createElement('i');
spinner.className = 'fa-solid fa-circle-notch fa-spin';
spinner.setAttribute('aria-hidden', 'true');
busyNote.append(spinner, tr('adding'));
function showError(message: string): void {
error.remove();
error.textContent = message;
if (message) {
form.insertBefore(error, busyNote.parentElement === form ? busyNote : null);
}
}
function setBusy(value: boolean): void {
displayName.disabled = value;
endpoint.disabled = value;
if (value) {
form.append(busyNote);
} else {
busyNote.remove();
}
}
let created: TauriTavernMcpServer | null = null;
let submitting = false;
const popup = new Popup(form, confirmType, '', {
okButton: tr('addServer'),
cancelButton: tr('cancel'),
allowVerticalScrolling: true,
onOpen: () => displayName.focus(),
onClosing: async (popup) => {
if (popup.result !== affirmative) {
return true;
}
if (submitting) {
return false;
}
const name = displayName.value.trim();
const target = endpoint.value.trim();
if (!name) {
showError(tr('nameRequired'));
return false;
}
if (!isHttpUrl(target)) {
showError(tr('endpointInvalid'));
return false;
}
showError('');
submitting = true;
setBusy(true);
try {
created = await create({ displayName: name, endpoint: target });
return true;
} catch (cause) {
showError(errorText(cause, tr('unknownError')));
return false;
} finally {
submitting = false;
setBusy(false);
}
},
});
await popup.show();
return created;
}

View file

@ -0,0 +1,70 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import {
confirmActivate,
confirmRemove,
openAddServerDialog,
promptServerName,
requireMcpApi,
tr,
waitForHostReady,
} from './host';
import { McpManagerApp, type McpManagerActions } from './McpManagerApp';
const CONTAINER_ID = 'mcp_manager_container';
function ensureContainer(): HTMLElement {
const existing = document.getElementById(CONTAINER_ID);
if (existing instanceof HTMLElement) {
return existing;
}
const column = document.getElementById('extensions_settings2');
if (!(column instanceof HTMLElement)) {
throw new Error('SillyTavern extension settings column is unavailable');
}
const container = document.createElement('div');
container.id = CONTAINER_ID;
container.className = 'extension_container';
const anchor = document.getElementById('skill_manager_container')
?? document.getElementById('agent_system_container');
if (anchor?.parentElement === column) {
anchor.insertAdjacentElement('afterend', container);
} else {
column.prepend(container);
}
return container;
}
async function mountMcpManager(): Promise<void> {
await waitForHostReady();
const api = requireMcpApi();
const initial = await api.servers.list();
const actions: McpManagerActions = {
addServer: () => openAddServerDialog(api.servers.create),
renameServer: async (server) => {
const displayName = await promptServerName(server);
if (!displayName) {
return null;
}
return api.servers.rename({ registrationId: server.id, displayName });
},
setState: api.servers.setState,
remove: api.servers.remove,
discover: api.servers.discover,
setPermission: api.tools.setPermission,
confirmActivate,
confirmRemove,
};
createRoot(ensureContainer()).render(
<StrictMode>
<McpManagerApp initial={initial} actions={actions} tr={tr} />
</StrictMode>,
);
}
await mountMcpManager();

View file

@ -0,0 +1,622 @@
.tt-mcp-root,
.tt-mcp-add-form,
.tt-mcp-confirm {
--tt-mcp-accent: var(--SmartThemeQuoteColor);
--tt-mcp-line: color-mix(in srgb, var(--SmartThemeBorderColor) 80%, transparent);
--tt-mcp-dim: var(--SmartThemeEmColor);
--tt-mcp-sink: var(--black30a);
--tt-mcp-danger: color-mix(in srgb, var(--fullred) 72%, var(--SmartThemeBodyColor));
}
.tt-mcp-root {
color: var(--SmartThemeBodyColor);
font-family: var(--mainFontFamily);
font-size: var(--mainFontSize);
text-align: left;
}
.tt-mcp-root .inline-drawer-content > * + * {
margin-top: 10px;
}
.tt-mcp-drawer-header {
gap: 8px;
}
.tt-mcp-title {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.tt-mcp-title > i {
color: var(--tt-mcp-accent);
}
.tt-mcp-root code,
.tt-mcp-root pre,
.tt-mcp-confirm code {
font-family: var(--monoFontFamily);
}
/* Neutralize the host's global dark `code` chip; chips re-declare their own skin below. */
.tt-mcp-root :where(code) {
padding: 0;
border: none;
background-color: transparent;
}
/* ── Toolbar & panel-level lines ─────────────────────────── */
.tt-mcp-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tt-mcp-count {
color: var(--tt-mcp-dim);
font-size: 0.8em;
white-space: nowrap;
}
.tt-mcp-toolbar .menu_button,
.tt-mcp-empty .menu_button {
margin: 0;
}
.tt-mcp-error {
display: block;
margin: 0;
color: var(--tt-mcp-danger);
font-size: 0.82em;
line-height: 1.35;
white-space: pre-wrap;
}
.tt-mcp-note {
display: flex;
align-items: baseline;
gap: 7px;
margin: 0;
color: var(--tt-mcp-dim);
font-size: 0.85em;
line-height: 1.4;
}
/* ── Storage issues ──────────────────────────────────────── */
.tt-mcp-alert {
padding: 4px 0 4px 10px;
border-left: 2px solid var(--tt-mcp-danger);
}
.tt-mcp-alert > b {
display: flex;
align-items: baseline;
gap: 7px;
font-size: 0.88em;
}
.tt-mcp-alert > b > i {
color: var(--tt-mcp-danger);
}
.tt-mcp-alert ul {
display: grid;
gap: 4px;
margin: 6px 0 0;
padding: 0;
list-style: none;
}
.tt-mcp-alert li {
display: grid;
gap: 1px;
}
.tt-mcp-alert code {
font-size: 0.78em;
overflow-wrap: anywhere;
}
.tt-mcp-alert span {
color: var(--tt-mcp-dim);
font-size: 0.82em;
white-space: pre-wrap;
}
/* ── Empty state ─────────────────────────────────────────── */
.tt-mcp-empty {
display: grid;
justify-items: center;
gap: 6px;
padding: 18px 12px 14px;
text-align: center;
}
.tt-mcp-empty > i {
color: color-mix(in srgb, var(--tt-mcp-accent) 65%, var(--tt-mcp-dim));
font-size: 1.5em;
}
.tt-mcp-empty > span {
max-width: 34ch;
color: var(--tt-mcp-dim);
font-size: 0.85em;
line-height: 1.4;
}
.tt-mcp-empty .menu_button {
margin-top: 4px;
}
/* ── Server list ─────────────────────────────────────────── */
.tt-mcp-servers {
display: grid;
}
.tt-mcp-server {
padding: 8px 0 9px;
}
.tt-mcp-server + .tt-mcp-server {
border-top: 1px solid var(--tt-mcp-line);
}
.tt-mcp-server-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
}
.tt-mcp-lamp {
width: 8px;
height: 8px;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 50%;
background-color: var(--tt-mcp-sink);
}
.tt-mcp-server.is-active .tt-mcp-lamp {
border-color: var(--tt-mcp-accent);
background-color: var(--tt-mcp-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--tt-mcp-accent) 15%, transparent);
}
.tt-mcp-identity {
display: grid;
min-width: 0;
gap: 1px;
}
.tt-mcp-name {
overflow: hidden;
font-size: 0.95em;
text-overflow: ellipsis;
white-space: nowrap;
}
.tt-mcp-endpoint {
overflow: hidden;
color: var(--tt-mcp-dim);
font-size: 0.75em;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── State pill & row icon buttons ───────────────────────── */
.tt-mcp-state {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 3px 9px;
color: var(--tt-mcp-dim);
font: inherit;
font-size: 0.78em;
border: 1px solid var(--tt-mcp-line);
border-radius: 999px;
background-color: transparent;
cursor: pointer;
white-space: nowrap;
}
.tt-mcp-state-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--tt-mcp-dim);
}
.tt-mcp-state.is-active {
color: var(--SmartThemeBodyColor);
border-color: color-mix(in srgb, var(--tt-mcp-accent) 55%, var(--tt-mcp-line));
}
.tt-mcp-state.is-active .tt-mcp-state-dot {
background-color: var(--tt-mcp-accent);
}
.tt-mcp-state:disabled {
cursor: default;
opacity: 0.55;
}
.tt-mcp-row-actions {
display: flex;
align-items: center;
gap: 2px;
}
.tt-mcp-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
color: var(--tt-mcp-dim);
font: inherit;
font-size: 0.82em;
border: none;
border-radius: 5px;
background: none;
cursor: pointer;
transition: color var(--animation-duration-2x), background-color var(--animation-duration-2x);
}
.tt-mcp-icon-btn:hover:not(:disabled) {
color: var(--SmartThemeBodyColor);
background-color: var(--tt-mcp-sink);
}
.tt-mcp-icon-btn.is-danger:hover:not(:disabled) {
color: var(--tt-mcp-danger);
}
.tt-mcp-icon-btn:disabled {
cursor: default;
opacity: 0.45;
}
.tt-mcp-chevron i {
transition: transform var(--animation-duration-2x);
}
.tt-mcp-server.is-expanded .tt-mcp-chevron i {
transform: rotate(180deg);
}
.tt-mcp-state:focus-visible,
.tt-mcp-icon-btn:focus-visible,
.tt-mcp-root summary:focus-visible {
outline: 2px solid var(--interactable-outline-color);
outline-offset: 1px;
}
/* ── Discovery console ───────────────────────────────────── */
.tt-mcp-console {
display: grid;
gap: 8px;
margin: 7px 0 1px 4px;
padding-left: 11px;
border-left: 2px solid var(--tt-mcp-line);
}
.tt-mcp-server.is-active .tt-mcp-console {
border-left-color: color-mix(in srgb, var(--tt-mcp-accent) 45%, var(--tt-mcp-line));
}
.tt-mcp-console > .menu_button {
justify-self: start;
margin: 0;
}
.tt-mcp-console-head {
display: flex;
align-items: center;
gap: 8px;
}
.tt-mcp-console-meta {
min-width: 0;
overflow: hidden;
color: var(--tt-mcp-dim);
font-size: 0.78em;
text-overflow: ellipsis;
white-space: nowrap;
}
.tt-mcp-console-count {
margin-left: auto;
color: var(--SmartThemeBodyColor);
font-size: 0.78em;
white-space: nowrap;
}
.tt-mcp-details {
font-size: 0.8em;
}
.tt-mcp-root summary {
color: var(--tt-mcp-dim);
cursor: pointer;
width: fit-content;
}
.tt-mcp-details ul {
display: grid;
gap: 6px;
margin: 6px 0 0;
padding-left: 18px;
}
.tt-mcp-details li span {
display: block;
color: var(--tt-mcp-dim);
white-space: pre-wrap;
}
.tt-mcp-details pre {
max-height: 220px;
overflow: auto;
margin: 6px 0 0;
padding: 7px 8px;
color: var(--SmartThemeBodyColor);
border: 1px solid var(--tt-mcp-line);
border-radius: 5px;
background-color: var(--tt-mcp-sink);
font-size: 0.85em;
line-height: 1.4;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
/* ── Tools ───────────────────────────────────────────────── */
.tt-mcp-tools {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
list-style: none;
}
.tt-mcp-tool {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 4px 10px;
}
.tt-mcp-tool + .tt-mcp-tool {
padding-top: 9px;
border-top: 1px solid var(--tt-mcp-line);
}
.tt-mcp-tool-title {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 7px;
}
.tt-mcp-tool-title > b {
font-size: 0.92em;
overflow-wrap: anywhere;
}
.tt-mcp-tool-title > code {
color: var(--tt-mcp-dim);
font-size: 0.75em;
overflow-wrap: anywhere;
}
.tt-mcp-tool-body {
display: grid;
grid-column: 1 / -1;
gap: 4px;
}
.tt-mcp-tool-body > p {
margin: 0;
color: color-mix(in srgb, var(--SmartThemeBodyColor) 82%, var(--tt-mcp-dim));
font-size: 0.82em;
line-height: 1.4;
white-space: pre-wrap;
}
.tt-mcp-params {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tt-mcp-params code {
padding: 1px 6px;
color: var(--tt-mcp-dim);
border: 1px solid var(--tt-mcp-line);
border-radius: 4px;
background-color: var(--tt-mcp-sink);
font-size: 0.72em;
line-height: 1.5;
}
.tt-mcp-params code.is-required {
color: var(--SmartThemeBodyColor);
border-color: color-mix(in srgb, var(--tt-mcp-accent) 60%, var(--tt-mcp-line));
}
/* ── Permission segmented control ────────────────────────── */
.tt-mcp-perm {
display: flex;
padding: 1px;
border: 1px solid var(--tt-mcp-line);
border-radius: 6px;
background-color: var(--tt-mcp-sink);
}
.tt-mcp-perm label {
position: relative;
display: flex;
cursor: pointer;
}
.tt-mcp-perm input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.tt-mcp-perm span {
display: block;
padding: 2px 8px;
color: var(--tt-mcp-dim);
border-radius: 5px;
font-size: 0.76em;
line-height: 1.6;
white-space: nowrap;
}
.tt-mcp-perm label.is-selected span {
color: var(--SmartThemeBodyColor);
background-color: color-mix(in srgb, var(--tt-mcp-accent) 30%, transparent);
}
.tt-mcp-perm input:focus-visible + span {
outline: 2px solid var(--interactable-outline-color);
outline-offset: 1px;
}
.tt-mcp-perm input:disabled,
.tt-mcp-perm input:disabled + span {
cursor: default;
}
.tt-mcp-perm input:disabled + span {
opacity: 0.6;
}
/* ── Stale tools ─────────────────────────────────────────── */
.tt-mcp-stale {
display: grid;
gap: 5px;
padding-top: 8px;
border-top: 1px dashed var(--tt-mcp-line);
}
.tt-mcp-stale > b {
color: var(--tt-mcp-dim);
font-size: 0.78em;
}
.tt-mcp-stale ul {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.tt-mcp-stale li {
display: flex;
align-items: center;
gap: 8px;
}
.tt-mcp-stale code {
min-width: 0;
overflow: hidden;
font-size: 0.78em;
text-overflow: ellipsis;
white-space: nowrap;
}
.tt-mcp-stale li > span {
margin-left: auto;
color: var(--tt-mcp-dim);
font-size: 0.76em;
white-space: nowrap;
}
.tt-mcp-stale .menu_button {
margin: 0;
font-size: 0.76em;
}
/* ── Add-server dialog form ──────────────────────────────── */
.tt-mcp-add-form {
display: grid;
gap: 6px;
min-width: min(360px, 62vw);
text-align: left;
}
.tt-mcp-add-form h3 {
margin: 0 0 4px;
}
.tt-mcp-add-form > label {
margin-top: 4px;
color: var(--tt-mcp-dim);
font-size: 0.8em;
}
.tt-mcp-add-form .text_pole {
width: 100%;
margin: 0;
}
.tt-mcp-add-hint {
color: var(--tt-mcp-dim);
font-size: 0.76em;
line-height: 1.4;
}
/* ── Activate / remove confirmation content ──────────────── */
.tt-mcp-confirm {
display: grid;
gap: 10px;
text-align: left;
}
.tt-mcp-confirm h3,
.tt-mcp-confirm p {
margin: 0;
}
.tt-mcp-confirm code {
padding: 8px 10px;
color: var(--SmartThemeBodyColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 5px;
background-color: var(--tt-mcp-sink);
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.tt-mcp-root .fa-spin {
animation: none;
}
.tt-mcp-icon-btn,
.tt-mcp-chevron i {
transition: none;
}
}

View file

@ -50,7 +50,7 @@ test('Rspack exposes a dedicated TauriTavern Dev Logs Vue entry', async () => {
const source = await readRepoFile('rspack.config.js');
assert.match(source, /['"]dev-logs['"]:\s*['"]\.\/src\/scripts\/tauri\/setting\/dev-logs-app\/index\.js['"]/);
assert.match(source, /listJavaScriptFiles\(['"]src\/scripts\/tauri\/setting\/dev-logs-app['"]\)/);
assert.match(source, /listScriptFiles\(['"]src\/scripts\/tauri\/setting\/dev-logs-app['"]\)/);
assert.match(source, /src\/scripts\/tauri\/setting\/dist/);
});

View file

@ -323,7 +323,7 @@ test('Rspack exposes a dedicated TauriTavern Sync Vue entry', async () => {
const source = await readRepoFile('rspack.config.js');
assert.match(source, /sync:\s*['"]\.\/src\/scripts\/tauri\/setting\/sync-app\/index\.js['"]/);
assert.match(source, /listJavaScriptFiles\(['"]src\/scripts\/tauri\/setting\/sync-app['"]\)/);
assert.match(source, /listScriptFiles\(['"]src\/scripts\/tauri\/setting\/sync-app['"]\)/);
assert.match(source, /src\/scripts\/tauri\/setting\/dist/);
});

15
tsconfig.ui.json Normal file
View file

@ -0,0 +1,15 @@
{
"extends": "./tsconfig.host.json",
"compilerOptions": {
"jsx": "react-jsx",
"isolatedModules": true,
"module": "ESNext",
"verbatimModuleSyntax": true,
"types": ["react", "react-dom"]
},
"include": [
"src/types.d.ts",
"src/scripts/extensions/**/*.ts",
"src/scripts/extensions/**/*.tsx"
]
}