feat(tamagotchi): basic MCP support (#144)

* refactor: tauri plugin
* fix: DO NOT auto format generated files
* fix: not format, it's normal generation
* feat: command wrapper
* feat: settings page to configure server
* fix: transparent background
This commit is contained in:
LemonNeko
2025-04-28 15:08:28 +08:00
committed by GitHub
parent 9fa24dbc87
commit bddd999d5d
32 changed files with 6196 additions and 502 deletions
+3
View File
@@ -152,6 +152,9 @@ settings:
messaging-discord:
description: Chat & voice chat over Discord
title: Discord
mcp-server:
description: Connect to the MCP server
title: MCP Server
speech:
description: Speech synthesis
sections:
+3
View File
@@ -141,6 +141,9 @@ settings:
messaging-discord:
description: Discord 发消息和语音聊天
title: Discord
mcp-server:
description: 连接到 MCP 服务器
title: MCP 服务器
speech:
description: 语音合成
sections:
@@ -2,3 +2,6 @@
# will have compiled files and executables
/target/
/gen/schemas
# Mcp plugin
/tauri-plugin-mcp/target/
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -1,15 +1,13 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = [ "you" ]
license = ""
repository = ""
description = "Airi tamagotchi app built with Tauri"
authors = [ "LemonNekoGH <self@lemonneko.moe>" ]
license = "MIT"
repository = "https://github.com/moeru-ai/airi"
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = [
@@ -19,7 +17,7 @@ crate-type = [
]
[build-dependencies]
tauri-build = { version = "2.0.5", features = [] }
tauri-build = { version = "2.2.0", features = [] }
[dependencies]
serde_json = "1.0"
@@ -32,3 +30,9 @@ tauri = { version = "2.3.1", features = [
] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-os = "2"
tauri-plugin-mcp = { path = "./tauri-plugin-mcp" }
[workspace]
members = [
"tauri-plugin-mcp"
]
@@ -3,12 +3,13 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
"main", "settings"
],
"permissions": [
"core:default",
"os:default",
"core:window:default",
"core:window:allow-start-dragging"
"core:window:allow-start-dragging",
"mcp:default"
]
}
+2 -1
View File
@@ -1,7 +1,7 @@
use tauri::{WebviewUrl, WebviewWindowBuilder, Manager};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy,TitleBarStyle};
use tauri::tray::{TrayIconBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::menu::{Menu, MenuItem};
use std::path::Path;
@@ -10,6 +10,7 @@ mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_mcp::Builder::default().build())
.plugin(tauri_plugin_os::init())
.setup(|app| {
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
[package]
name = "tauri-plugin-mcp"
version = "0.1.0"
description = "A Tauri plugin for interacting with MCP servers"
authors = [ "LemonNekoGH <self@lemonneko.moe>" ]
license = "MIT"
repository = "https://github.com/moeru-ai/airi"
edition = "2021"
rust-version = "1.77.2"
links = "tauri-plugin-mcp"
[lib]
name = "tauri_plugin_mcp"
crate-type = [
"staticlib",
"cdylib",
"rlib"
]
[dependencies]
tauri = "2.3.1"
serde_json = "1.0"
serde = { version = "1.0", features = [ "derive" ] }
log = "0.4"
rmcp = { version = "0.1", features = [
"client",
"transport-child-process"
] }
tokio = { version = "1", features = [ "full" ] }
anyhow = "1"
[build-dependencies]
tauri-plugin = { version = "2.2", features = [ "build" ] }
@@ -0,0 +1,27 @@
# `tauri-plugin-mcp`
A Tauri plugin for MCP.
These files are generated from `tauri-plugin` crate:
- `permissions/autogenerated`
- `permissions/schemas`
## TODO List
- [ ] Move to a single repository
- [ ] Server connection
- [ ] stdio
- [ ] SSE
- [x] Tools
- [x] List tools
- [x] Simple: Names only
- [ ] With description, parameters, returns
- [x] Call tool
- [x] Simple: Name only
- [x] With parameters
- [x] Returns
- [ ] Image returns
- [ ] Prompts...
- [ ] Resources...
- [ ] JavaScript package to call commands conveniently
@@ -0,0 +1,10 @@
const COMMANDS: &[&str] = &[
"connect_server",
"disconnect_server",
"list_tools",
"call_tool",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS).build();
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-call-tool"
description = "Enables the call_tool command without any pre-configured scope."
commands.allow = ["call_tool"]
[[permission]]
identifier = "deny-call-tool"
description = "Denies the call_tool command without any pre-configured scope."
commands.deny = ["call_tool"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-connect-server"
description = "Enables the connect_server command without any pre-configured scope."
commands.allow = ["connect_server"]
[[permission]]
identifier = "deny-connect-server"
description = "Denies the connect_server command without any pre-configured scope."
commands.deny = ["connect_server"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-disconnect-server"
description = "Enables the disconnect_server command without any pre-configured scope."
commands.allow = ["disconnect_server"]
[[permission]]
identifier = "deny-disconnect-server"
description = "Denies the disconnect_server command without any pre-configured scope."
commands.deny = ["disconnect_server"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-list-tools"
description = "Enables the list_tools command without any pre-configured scope."
commands.allow = ["list_tools"]
[[permission]]
identifier = "deny-list-tools"
description = "Denies the list_tools command without any pre-configured scope."
commands.deny = ["list_tools"]
@@ -0,0 +1,130 @@
## Default Permission
This permission set configures what kind of
operations are available from the mcp plugin.
#### Granted Permissions
All operations are enabled by default.
#### This default permission set includes the following:
- `allow-connect-server`
- `allow-disconnect-server`
- `allow-list-tools`
- `allow-call-tool`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`mcp:allow-call-tool`
</td>
<td>
Enables the call_tool command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`mcp:deny-call-tool`
</td>
<td>
Denies the call_tool command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`mcp:allow-connect-server`
</td>
<td>
Enables the connect_server command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`mcp:deny-connect-server`
</td>
<td>
Denies the connect_server command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`mcp:allow-disconnect-server`
</td>
<td>
Enables the disconnect_server command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`mcp:deny-disconnect-server`
</td>
<td>
Denies the disconnect_server command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`mcp:allow-list-tools`
</td>
<td>
Enables the list_tools command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`mcp:deny-list-tools`
</td>
<td>
Denies the list_tools command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,17 @@
"$schema" = "schemas/schema.json"
[default]
description = """
This permission set configures what kind of
operations are available from the mcp plugin.
#### Granted Permissions
All operations are enabled by default.
"""
permissions = [
"allow-connect-server",
"allow-disconnect-server",
"allow-list-tools",
"allow-call-tool"
]
@@ -0,0 +1,354 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the call_tool command without any pre-configured scope.",
"type": "string",
"const": "allow-call-tool",
"markdownDescription": "Enables the call_tool command without any pre-configured scope."
},
{
"description": "Denies the call_tool command without any pre-configured scope.",
"type": "string",
"const": "deny-call-tool",
"markdownDescription": "Denies the call_tool command without any pre-configured scope."
},
{
"description": "Enables the connect_server command without any pre-configured scope.",
"type": "string",
"const": "allow-connect-server",
"markdownDescription": "Enables the connect_server command without any pre-configured scope."
},
{
"description": "Denies the connect_server command without any pre-configured scope.",
"type": "string",
"const": "deny-connect-server",
"markdownDescription": "Denies the connect_server command without any pre-configured scope."
},
{
"description": "Enables the disconnect_server command without any pre-configured scope.",
"type": "string",
"const": "allow-disconnect-server",
"markdownDescription": "Enables the disconnect_server command without any pre-configured scope."
},
{
"description": "Denies the disconnect_server command without any pre-configured scope.",
"type": "string",
"const": "deny-disconnect-server",
"markdownDescription": "Denies the disconnect_server command without any pre-configured scope."
},
{
"description": "Enables the list_tools command without any pre-configured scope.",
"type": "string",
"const": "allow-list-tools",
"markdownDescription": "Enables the list_tools command without any pre-configured scope."
},
{
"description": "Denies the list_tools command without any pre-configured scope.",
"type": "string",
"const": "deny-list-tools",
"markdownDescription": "Denies the list_tools command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the mcp plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n#### This default permission set includes:\n\n- `allow-connect-server`\n- `allow-disconnect-server`\n- `allow-list-tools`\n- `allow-call-tool`",
"type": "string",
"const": "default",
"markdownDescription": "This permission set configures what kind of\noperations are available from the mcp plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n#### This default permission set includes:\n\n- `allow-connect-server`\n- `allow-disconnect-server`\n- `allow-list-tools`\n- `allow-call-tool`"
}
]
}
}
}
@@ -0,0 +1,102 @@
use rmcp::{model::{CallToolResult, Tool}, service::RunningService, transport::TokioChildProcess, RoleClient, ServiceExt};
use serde_json::{Map, Value};
use tauri::{plugin::{self, TauriPlugin}, Manager, Runtime};
use tokio::sync::Mutex;
use rmcp::model::CallToolRequestParam;
use tauri::State;
use tokio::process::Command;
pub struct McpState {
pub client: Option<RunningService<RoleClient, ()>>,
}
#[tauri::command]
async fn connect_server(state: State<'_, Mutex<McpState>>, command: String, args: Vec<String>) -> Result<(), String> {
let mut state = state.lock().await;
if state.client.is_some() {
return Err("Client already connected".to_string());
}
let child_process = TokioChildProcess::new(
Command::new(command).args(args)
).unwrap();
let service: RunningService<RoleClient, ()> = ().serve(child_process).await.unwrap();
state.client = Some(service);
Ok(())
}
#[tauri::command]
async fn disconnect_server(state: State<'_, Mutex<McpState>>) -> Result<(), String> {
let mut state = state.lock().await;
if state.client.is_none() {
return Err("Client not connected".to_string());
}
state.client.take().unwrap().cancel().await.unwrap();
state.client = None;
Ok(())
}
#[tauri::command]
async fn list_tools(state: State<'_, Mutex<McpState>>) -> Result<Vec<Tool>, String> {
let state = state.lock().await;
let client = state.client.as_ref();
if client.is_none() {
return Err("Client not connected".to_string());
}
let list_tools_result = client.unwrap().list_tools(Default::default()).await.unwrap();
let tools = list_tools_result.tools;
Ok(tools)
}
#[tauri::command]
async fn call_tool(state: State<'_, Mutex<McpState>>, name: String, args: Option<Map<String, Value>>) -> Result<CallToolResult, String> {
println!("Calling tool: {:?}", name);
println!("Arguments: {:?}", args);
let state = state.lock().await;
let client = state.client.as_ref();
if client.is_none() {
return Err("Client not connected".to_string());
}
let call_tool_result = client.unwrap().call_tool(CallToolRequestParam { name: name.into(), arguments: args }).await.unwrap();
println!("Tool result: {:?}", call_tool_result);
Ok(call_tool_result)
}
pub struct Builder;
impl Default for Builder {
fn default() -> Self {
Self {}
}
}
impl Builder {
pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
println!("Building MCP plugin");
plugin::Builder::new("mcp")
.invoke_handler(tauri::generate_handler![
connect_server,
disconnect_server,
list_tools,
call_tool
])
.setup(|app_handle, _| {
app_handle.manage(Mutex::new(McpState { client: None }));
Ok(())
})
.build()
}
}
@@ -41,6 +41,10 @@ const routeHeaderMetadataMap = computed(() => {
subtitle: t('settings.title'),
title: t('settings.pages.modules.speech.title'),
},
'/settings/modules/mcp': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.mcp-server.title'),
},
'/settings/modules/hearing': {
subtitle: t('settings.title'),
title: t('settings.pages.modules.hearing.title'),
+22 -2
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components'
import { computed } from 'vue'
import { useMcpStore } from '@proj-airi/stage-ui/stores'
import { connectServer } from '@proj-airi/stage-ui/utils/tauri-plugin-mcp'
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import InteractiveArea from '../components/InteractiveArea.vue'
import { useWindowShortcuts } from '../composables/window-shortcuts'
@@ -10,6 +13,9 @@ import { WindowControlMode } from '../types/window-controls'
const windowStore = useWindowControlStore()
useWindowShortcuts()
const mcpStore = useMcpStore()
const { connected, serverCmd, serverArgs } = storeToRefs(mcpStore)
const modeIndicatorClass = computed(() => {
switch (windowStore.controlMode) {
case WindowControlMode.MOVE:
@@ -22,6 +28,20 @@ const modeIndicatorClass = computed(() => {
return ''
}
})
onMounted(async () => {
if (connected.value)
return
if (!serverCmd.value || !serverArgs.value)
return
try {
await connectServer(serverCmd.value, serverArgs.value.split(' '))
connected.value = true
}
catch (error) {
console.error(error)
}
})
</script>
<template>
@@ -65,7 +85,7 @@ const modeIndicatorClass = computed(() => {
>
<div class="absolute h-32 w-full flex items-center justify-center b-2 b-pink bg-white">
<div class="wall absolute top-0 h-8" />
<div data-tauri-drag-region class="text-primary-300 absolute left-0 top-0 h-full w-full flex animate-flash animate-duration-5s animate-count-infinite items-center justify-center text-1.5rem font-bold">
<div data-tauri-drag-region class="absolute left-0 top-0 h-full w-full flex animate-flash animate-duration-5s animate-count-infinite items-center justify-center text-1.5rem text-primary-300 font-bold">
DRAG HERE TO MOVE
</div>
<div data-tauri-drag-region class="wall absolute bottom-0 h-8" />
@@ -101,6 +101,14 @@ const modulesList = computed<Module[]>(() => [
to: '',
configured: false,
},
{
id: 'mcp-server',
name: t('settings.pages.modules.mcp-server.title'),
description: t('settings.pages.modules.mcp-server.description'),
icon: 'i-solar:server-bold-duotone',
to: '/settings/modules/mcp',
configured: false,
},
])
const {
@@ -0,0 +1,147 @@
<script setup lang="ts">
import type { Tool } from '@proj-airi/stage-ui/utils/tauri-plugin-mcp'
import { useMcpStore } from '@proj-airi/stage-ui/stores'
import { connectServer, disconnectServer, listTools } from '@proj-airi/stage-ui/utils/tauri-plugin-mcp'
import {
FieldInput,
} from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
const mcpStore = useMcpStore()
const connecting = ref(false)
const {
serverCmd,
serverArgs,
connected,
} = storeToRefs(mcpStore)
const tools = ref<Tool[]>([])
async function connect() {
connecting.value = true
try {
await connectServer(serverCmd.value, serverArgs.value.split(' '))
connected.value = true
}
catch (e) {
const error = e as string
console.error(error)
if (error.includes('already connected')) {
connected.value = true
}
}
finally {
connecting.value = false
}
}
async function disconnect() {
await disconnectServer()
connected.value = false
tools.value = []
}
async function getTools() {
tools.value = await listTools()
}
</script>
<template>
<div flex="~ col md:row gap-6">
<div flex="~ col gap-6" class="w-full md:w-[60%]">
<div w-full rounded-xl>
<div flex="~ col gap-4">
<FieldInput
v-model="serverCmd"
type="text"
label="Server Command"
description="Enter the server command to run"
placeholder="docker"
:disabled="connecting || connected"
/>
<FieldInput
v-model="serverArgs"
type="text"
label="Server Arguments"
description="Enter the server command arguments"
placeholder="run -i --rm -e ADB_HOST=host.docker.internal ghcr.io/lemonnekogh/airi-android:v0.1.0"
:disabled="connecting || connected"
/>
<div flex="~ row" gap-4>
<button
v-if="!connected"
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="connecting || !serverCmd || !serverArgs"
:class="{ 'opacity-50 cursor-not-allowed': connecting || !serverCmd || !serverArgs }"
bg="neutral-700 dark:neutral-300" @click="connect"
>
<div flex="~ row" items-center gap-2>
<div i-solar:play-circle-bold-duotone />
<span>Connect</span>
</div>
</button>
<button
v-else border="primary-300 dark:primary-800 solid 2"
transition="border duration-250 ease-in-out" rounded-lg px-4 py-2 text-sm @click="disconnect"
>
<div flex="~ row" items-center gap-2>
<div i-solar:stop-circle-bold-duotone />
<span>Disconnect</span>
</div>
</button>
<button
v-if="connected"
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
rounded-lg px-4 text="neutral-100 dark:neutral-900" py-2 text-sm
:disabled="connecting"
:class="{ 'opacity-50 cursor-not-allowed': connecting }"
bg="neutral-700 dark:neutral-300" @click="getTools"
>
<div flex="~ row" items-center gap-2>
<div solar:list-arrow-down-line-duotone />
<span>List Tools</span>
</div>
</button>
</div>
<div v-if="tools.length > 0" flex="~ col gap-4">
<div v-for="tool in tools" :key="tool.name" border="neutral-200 dark:neutral-800 solid 2" rounded-lg p-4>
<div text="neutral-900 dark:neutral-100" text-sm>
{{ tool.name }}
</div>
<div text="neutral-500 dark:neutral-400" text-xs>
{{ tool.description }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-motion
text="neutral-200/50 dark:neutral-600/20" pointer-events-none
fixed top="[calc(100dvh-15rem)]" bottom-0 right--5 z--1
:initial="{ scale: 0.9, opacity: 0, x: 20 }"
:enter="{ scale: 1, opacity: 1, x: 0 }"
:duration="500"
size-60
flex items-center justify-center
>
<div text="60" i-solar:user-speak-rounded-bold-duotone />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -18,12 +18,10 @@ body,
}
html {
background: var(--bg-color);
transition: all 0.3s ease-in-out;
}
html.dark {
--bg-color: var(--bg-color-dark);
color-scheme: dark;
}
+2
View File
@@ -84,6 +84,7 @@ words:
- intlify
- Kawaii
- kwaa
- lemonnekogh
- libsodium
- listhen
- live2dcubismcore
@@ -140,6 +141,7 @@ words:
- qwen
- rehype
- reka
- rmcp
- rushstack
- Saccade
- saccades
+1
View File
@@ -9,6 +9,7 @@ export default antfu(
'**/assets/js/**',
'**/assets/live2d/models/**',
'apps/stage-tamagotchi/out/**',
'apps/stage-tamagotchi/src-tauri/**',
'**/drizzle/**',
'**/.astro/**',
],
+1
View File
@@ -1,6 +1,7 @@
export * from './audio'
export * from './chat'
export * from './llm'
export * from './mcp'
export * from './modules'
export * from './providers'
export * from './server'
+6
View File
@@ -5,6 +5,8 @@ import { listModels } from '@xsai/model'
import { streamText } from '@xsai/stream-text'
import { defineStore } from 'pinia'
import { mcp } from '../tools'
export const useLLM = defineStore('llm', () => {
async function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: {
headers?: Record<string, string>
@@ -13,8 +15,12 @@ export const useLLM = defineStore('llm', () => {
return await streamText({
...chatProvider.chat(model),
maxSteps: 10,
messages,
headers,
tools: [
...await mcp(),
],
})
}
+15
View File
@@ -0,0 +1,15 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useMcpStore = defineStore('mcp', () => {
const serverCmd = useLocalStorage('settings/mcp/server-cmd', '')
const serverArgs = useLocalStorage('settings/mcp/server-args', '')
const connected = ref(false)
return {
serverCmd,
serverArgs,
connected,
}
})
+1
View File
@@ -0,0 +1 @@
export * from './mcp'
+64
View File
@@ -0,0 +1,64 @@
import { tool } from '@xsai/tool'
import { z } from 'zod'
import { callTool, connectServer, disconnectServer, listTools } from '../utils/tauri-plugin-mcp'
const tools = [
tool({
name: 'mcp_list_tools',
description: 'List all tools available on the MCP server',
execute: async (_, __) => {
return await listTools()
},
parameters: z.object({}),
returns: z.object({}),
}),
tool({
name: 'mcp_connect_server',
description: 'Connect to the MCP server',
execute: async ({ command, args }) => {
await connectServer(command, args)
return 'success'
},
parameters: z.object({
command: z.string().describe('The command to connect to the MCP server'),
args: z.array(z.string()).describe('The arguments to pass to the MCP server'),
}),
returns: z.string().describe('If "success", the connection to the MCP server is successful. Otherwise, the connection fails.'),
}),
tool({
name: 'mcp_disconnect_server',
description: 'Disconnect from the MCP server',
execute: async () => {
await disconnectServer()
return 'success'
},
parameters: z.object({}),
returns: z.string().describe('If "success", the connection to the MCP server is successful. Otherwise, the connection fails.'),
}),
tool({
name: 'mcp_call_tool',
description: 'Call a tool on the MCP server',
execute: async ({ name, parameters }) => {
const parametersObject = Object.fromEntries(parameters.map(({ name, value }) => [name, value]))
const result = await callTool(name, parametersObject)
return result
},
parameters: z.object({
name: z.string().describe('The name of the tool to call'),
parameters: z.array(z.object({ // Why LLM cannot understand record type?
name: z.string().describe('The name of the parameter'),
value: z.any().describe('The value of the parameter'),
})).describe('The parameters to pass to the tool'),
}),
returns: z.object({
content: z.array(z.object({
type: z.string().describe('The type of the content'),
text: z.string().describe('The text of the content'),
})).describe('The content of the tool call result'),
isError: z.boolean().describe('Whether the tool call is an error'),
}).describe('The result of the tool call'),
}),
]
export const mcp = async () => Promise.all(tools)
@@ -0,0 +1,44 @@
// TODO: move to plugin package when it's ready
import { invoke } from '@tauri-apps/api/core'
export interface ToolInputSchema {
required: string[]
title: string
type: 'object'
properties: Record<string, {
title: string
type: string
default?: any
}>
}
export interface CallToolResult {
content: {
type: string
text: string
}[]
isError: boolean
}
export interface Tool {
name: string
description: string
inputSchema: ToolInputSchema
}
export async function connectServer(command: string, args: string[]) {
await invoke('plugin:mcp|connect_server', { command, args })
}
export async function disconnectServer() {
await invoke('plugin:mcp|disconnect_server')
}
export async function listTools(): Promise<Tool[]> {
return await invoke('plugin:mcp|list_tools')
}
export async function callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
return await invoke('plugin:mcp|call_tool', { name, args })
}