diff --git a/docs/issues/plugin-settings-surface-isolation/plan.md b/docs/issues/plugin-settings-surface-isolation/plan.md
new file mode 100644
index 000000000..8343cb84a
--- /dev/null
+++ b/docs/issues/plugin-settings-surface-isolation/plan.md
@@ -0,0 +1,67 @@
+# Plugin Settings Surface Isolation Plan
+
+## Implementation Approach
+
+- Show the plugin settings action whenever the plugin list item exposes a settings contribution,
+ regardless of whether the plugin is currently enabled.
+- Remove the enablement guard from the dedicated plugin settings window flow.
+- In development, prefer workspace plugin directories over user-data installation directories when
+ discovering official plugins so stale local installs cannot mask newer settings metadata.
+- In development, re-copy workspace directory plugins into userData installs during activation so
+ script-only fixes land even when `plugin.json` is unchanged.
+- Treat an installed official plugin as stale when its hydrated manifest differs from the current
+ official manifest, even if the version string is unchanged.
+- Preserve plugin-local `config.json` when reinstalling a stale official plugin so credentials do
+ not disappear during self-healing.
+- When discovery rejects an installed official plugin as unsupported or untrusted, remove the
+ persisted installation record and disable plugin-owned MCP servers, settings resources, and tool
+ policies before initialization tries to reactivate them.
+- Keep Feishu's MCP bootstrap self-contained with only Node builtins in the installed entrypoint,
+ and use a built-in stdio warning responder when credentials are missing.
+- Clear the Feishu settings page message banner on non-error MCP states so prior failures do not
+ linger after recovery.
+- Pin the Feishu `npx` package invocation and only pass through an explicit registry override from
+ the environment.
+- Replace Feishu's blanket MCP auto-approval with an empty default allowlist.
+- Resolve settings contributions from the current official manifest instead of trusting an older
+ installed manifest copy, while still reusing installed file paths when those assets exist.
+- Resolve plugin settings contributions from the installed plugin manifest when stored plugin
+ resource records are absent or no longer point to valid files.
+- Materialize packaged official plugin assets on demand when a settings contribution exists but no
+ installed plugin directory is available yet.
+- Reuse the same resolved settings contribution for plugin list serialization and for opening the
+ dedicated plugin settings window.
+- Filter plugin-owned MCP servers out of the existing global MCP settings renderer so plugin MCP
+ remains a plugin-local concern while existing built-in and user-managed MCP behavior stays
+ unchanged.
+
+## Affected Areas
+
+- `src/main/presenter/pluginPresenter/index.ts`
+- `plugins/feishu/settings/assets/index.js`
+- `plugins/feishu/mcp/serve.mjs`
+- `plugins/feishu/plugin.json`
+- `src/renderer/src/components/mcp-config/components/McpServers.vue`
+- Focused presenter and renderer regression tests
+
+## Test Strategy
+
+- Add a renderer regression test covering a disabled plugin that still shows the settings action.
+- Add a main-process regression test covering plugin settings availability and opening when stored
+ resources are missing.
+- Add a main-process regression test covering opening settings for a disabled packaged plugin.
+- Add a main-process regression test covering startup self-heal for stale same-version installs.
+- Add a main-process regression test covering dev-directory sync when only plugin files changed.
+- Add a main-process regression test covering cleanup when discovery rejects a persisted official
+ plugin installation.
+- Add a regression assertion that the Feishu installed MCP bootstrap does not statically import host
+ SDK packages.
+- Add focused Feishu regression assertions for the pinned bootstrap package version, registry
+ override behavior, safer auto-approve defaults, and stale settings error clearing.
+- Add a renderer regression test covering plugin-owned MCP servers being hidden from the global MCP
+ settings list.
+
+## Risks
+
+- Low to moderate. Manifest fallback must only return settings entries whose installed files exist,
+ or the UI could expose an unusable settings action.
diff --git a/docs/issues/plugin-settings-surface-isolation/spec.md b/docs/issues/plugin-settings-surface-isolation/spec.md
new file mode 100644
index 000000000..1d4f778d1
--- /dev/null
+++ b/docs/issues/plugin-settings-surface-isolation/spec.md
@@ -0,0 +1,52 @@
+# Plugin Settings Surface Isolation
+
+## User Need
+
+As a user enabling the Feishu plugin, I need an immediate settings entry on the plugin card so I can
+configure the plugin, and I need the plugin-owned MCP server to stay inside the plugin experience
+instead of being mixed into the existing global settings or MCP settings surfaces.
+
+## Acceptance Criteria
+
+- Plugins with a declared settings contribution expose their settings action even while disabled so
+ users can configure required credentials before enabling the plugin.
+- Plugin settings contributions still resolve when an older installed plugin copy lags behind the
+ current official manifest metadata.
+- In development, when a workspace plugin and an installed plugin directory share the same official
+ plugin id, settings metadata resolves from the workspace plugin before the stale installed copy.
+- Official plugins reinstall when a same-version installed copy is stale, so outdated MCP entrypoints
+ cannot survive on version equality alone.
+- In development, official plugin directory installs stay synchronized with workspace files even when
+ only non-manifest files changed.
+- Discovery that rejects an official plugin as unsupported or untrusted clears its persisted
+ installation record and plugin-owned runtime resources before startup activation can reuse them.
+- Enabling an official plugin with a declared settings contribution exposes the plugin settings
+ action on the Plugins settings page without depending on previously persisted resource records.
+- Opening plugin settings still works when persisted plugin resource records are missing or stale,
+ as long as the installed plugin manifest still declares a valid settings contribution.
+- Reinstalling a stale official plugin preserves plugin-local configuration such as `config.json`.
+- Plugin-owned MCP entrypoints remain runnable after installation into userData and must not rely on
+ static imports from the workspace or app-level `node_modules`.
+- The Feishu plugin settings page clears stale MCP error text whenever the Feishu MCP is not in an
+ error state.
+- The Feishu MCP bootstrap launches a pinned upstream package version and only honors explicit
+ registry overrides instead of injecting a hardcoded registry fallback.
+- The Feishu plugin manifest does not auto-approve every MCP tool call by default.
+- Global MCP settings do not render plugin-owned MCP servers identified by `source: plugin`.
+- Plugin-owned MCP runtime status remains available from plugin-specific settings/status surfaces.
+
+## Constraints
+
+- Keep plugin-owned MCP server configs in the existing MCP config store for runtime compatibility.
+- Preserve the existing plugin settings window flow and plugin manifest contract.
+- Packaged official plugins may need to materialize their settings assets before the plugin is
+ enabled so the settings window can load from a real file path.
+
+## Non-goals
+
+- Redesigning plugin installation, runtime detection, or plugin settings UX.
+- Changing core MCP lifecycle behavior beyond renderer visibility for plugin-owned servers.
+
+## Open Questions
+
+None.
diff --git a/docs/issues/plugin-settings-surface-isolation/tasks.md b/docs/issues/plugin-settings-surface-isolation/tasks.md
new file mode 100644
index 000000000..cacf2a539
--- /dev/null
+++ b/docs/issues/plugin-settings-surface-isolation/tasks.md
@@ -0,0 +1,17 @@
+# Plugin Settings Surface Isolation Tasks
+
+- [x] Allow disabled plugins to expose and open their settings contribution.
+- [x] Add plugin settings contribution fallback in `PluginPresenter`.
+- [x] Prefer current official plugin settings metadata when an installed copy is stale.
+- [x] Prefer workspace official plugin directories over stale installed copies during dev discovery.
+- [x] Reinstall stale same-version official plugins and preserve `config.json` during refresh.
+- [x] Keep dev directory plugin installs synced even when only file contents changed.
+- [x] Clear persisted plugin installation state when discovery rejects unsupported or untrusted
+ official plugins.
+- [x] Keep Feishu installed MCP bootstrap self-contained and free of static host SDK imports.
+- [x] Clear stale MCP error text from the Feishu settings page after healthy status refreshes.
+- [x] Pin the Feishu MCP bootstrap package and remove the hardcoded registry fallback.
+- [x] Replace Feishu's blanket MCP auto-approve default with an empty allowlist.
+- [x] Hide plugin-owned MCP servers from the global MCP settings list.
+- [x] Add focused regression coverage for presenter and renderer behavior.
+- [x] Run focused validation and repo-required format/i18n/lint checks as feasible.
diff --git a/docs/issues/plugin-skill-tool-guidance/plan.md b/docs/issues/plugin-skill-tool-guidance/plan.md
new file mode 100644
index 000000000..0b44e88cc
--- /dev/null
+++ b/docs/issues/plugin-skill-tool-guidance/plan.md
@@ -0,0 +1,28 @@
+# Plugin Skill Tool Guidance Plan
+
+## Implementation Approach
+
+- Add a `skills` contribution to the Feishu plugin manifest that points at a plugin-owned agent
+ skill folder.
+- Create a `SKILL.md` file for the Feishu plugin that frames `feishu-tools` as an MCP server tool
+ surface and tells the model to invoke matching tools directly for Feishu/Lark tasks.
+- Keep the skill generic enough to work with whichever Feishu/Lark tools are currently exposed by
+ the active MCP preset, using the live tool names and descriptions as the source of truth.
+- Add a focused regression assertion that the Feishu manifest and skill file stay wired together.
+
+## Affected Areas
+
+- `plugins/feishu/plugin.json`
+- `plugins/feishu/skills/feishu-tools/SKILL.md`
+- `test/main/presenter/pluginPresenter.test.ts`
+
+## Test Strategy
+
+- Add a source-level regression test asserting that the Feishu plugin manifest declares the plugin
+ skill contribution.
+- Assert that the skill file includes explicit MCP routing guidance so the regression catches future
+ removals of the usage instructions.
+
+## Risks
+
+- Low. The change adds guidance metadata but does not alter plugin startup or runtime behavior.
diff --git a/docs/issues/plugin-skill-tool-guidance/spec.md b/docs/issues/plugin-skill-tool-guidance/spec.md
new file mode 100644
index 000000000..824fd6840
--- /dev/null
+++ b/docs/issues/plugin-skill-tool-guidance/spec.md
@@ -0,0 +1,34 @@
+# Plugin Skill Tool Guidance
+
+## User Need
+
+As a user who already enabled the Feishu plugin and can see its tools in DeepChat, I need the AI to
+understand that the plugin exposes MCP tools for Feishu/Lark work so it invokes those tools directly
+instead of asking me to classify the plugin type or explain how to call it.
+
+## Acceptance Criteria
+
+- The Feishu plugin declares an agent skill contribution in its manifest so DeepChat can register a
+ plugin-owned skill alongside the MCP server.
+- The skill explicitly tells the model that the plugin is an MCP tool surface and that it should not
+ ask the user to classify the plugin as MCP, CLI, or another type.
+- The skill gives direct routing guidance for common Feishu/Lark requests such as documents,
+ spreadsheets, knowledge content, and other supported workspace artifacts.
+- The skill explains that available Feishu/Lark tools depend on the current MCP preset and that the
+ model should use currently exposed tool names and descriptions as the source of truth.
+
+## Constraints
+
+- Keep the change within the Feishu plugin manifest and skill assets.
+- Do not redesign plugin MCP startup, settings UX, or global tool routing.
+- Keep the guidance compatible with the current plugin skill registration path in `PluginPresenter`.
+
+## Non-goals
+
+- Changing Feishu MCP tool implementations.
+- Rewriting DeepChat's global tool-selection prompt.
+- Adding renderer UI for skill management.
+
+## Open Questions
+
+None.
diff --git a/docs/issues/plugin-skill-tool-guidance/tasks.md b/docs/issues/plugin-skill-tool-guidance/tasks.md
new file mode 100644
index 000000000..66ac35c4d
--- /dev/null
+++ b/docs/issues/plugin-skill-tool-guidance/tasks.md
@@ -0,0 +1,6 @@
+# Plugin Skill Tool Guidance Tasks
+
+- [x] Add a Feishu plugin `skills` contribution in `plugin.json`.
+- [x] Create the Feishu plugin `SKILL.md` guidance for MCP tool usage.
+- [x] Add focused regression coverage for the manifest-to-skill wiring.
+- [x] Run focused validation and repo-required format/i18n/lint checks as feasible.
diff --git a/plugins/feishu/mcp/serve.mjs b/plugins/feishu/mcp/serve.mjs
new file mode 100644
index 000000000..0793b9216
--- /dev/null
+++ b/plugins/feishu/mcp/serve.mjs
@@ -0,0 +1,199 @@
+#!/usr/bin/env node
+import { readFileSync, existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { dirname, join } from 'node:path'
+import { spawn } from 'node:child_process'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const pluginRoot = join(__dirname, '..')
+const WARNING_TEXT =
+ 'Feishu/Lark credentials are not configured. Please open the plugin settings and set your App ID and App Secret, then restart the MCP server.'
+const LARK_MCP_PACKAGE = '@larksuiteoapi/lark-mcp@0.5.1'
+
+function loadConfig() {
+ const configPath = join(pluginRoot, 'config.json')
+ if (!existsSync(configPath)) return null
+ try {
+ return JSON.parse(readFileSync(configPath, 'utf-8'))
+ } catch {
+ return null
+ }
+}
+
+const config = loadConfig()
+const appId = config?.appId || process.env.FEISHU_APP_ID || ''
+const appSecret = config?.appSecret || process.env.FEISHU_APP_SECRET || ''
+const brand = config?.brand || process.env.FEISHU_BRAND || 'feishu'
+const preset = config?.preset || ''
+
+function sendFrame(message) {
+ const body = JSON.stringify(message)
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`)
+}
+
+function sendResult(id, result) {
+ sendFrame({ jsonrpc: '2.0', id, result })
+}
+
+function sendError(id, code, message) {
+ sendFrame({
+ jsonrpc: '2.0',
+ id,
+ error: {
+ code,
+ message
+ }
+ })
+}
+
+function handleWarningRequest(message) {
+ if (message.id == null || typeof message.method !== 'string') {
+ return
+ }
+
+ switch (message.method) {
+ case 'initialize':
+ sendResult(message.id, {
+ protocolVersion: message.params?.protocolVersion ?? '2024-11-05',
+ capabilities: {
+ tools: {}
+ },
+ serverInfo: {
+ name: 'feishu-tools',
+ version: '0.1.0'
+ },
+ instructions: WARNING_TEXT
+ })
+ return
+ case 'ping':
+ case 'logging/setLevel':
+ sendResult(message.id, {})
+ return
+ case 'tools/list':
+ sendResult(message.id, {
+ tools: [
+ {
+ name: 'feishu_configure',
+ description:
+ 'Feishu/Lark is not configured. Open plugin settings to set App ID and App Secret.',
+ inputSchema: {
+ type: 'object',
+ properties: {},
+ additionalProperties: false
+ }
+ }
+ ]
+ })
+ return
+ case 'tools/call':
+ sendResult(message.id, {
+ content: [
+ {
+ type: 'text',
+ text: WARNING_TEXT
+ }
+ ],
+ isError: true
+ })
+ return
+ case 'resources/list':
+ sendResult(message.id, { resources: [] })
+ return
+ case 'prompts/list':
+ sendResult(message.id, { prompts: [] })
+ return
+ default:
+ sendError(message.id, -32601, `Method not found: ${message.method}`)
+ }
+}
+
+function startWarningServer() {
+ let buffer = Buffer.alloc(0)
+
+ process.stdin.on('data', (chunk) => {
+ buffer = Buffer.concat([buffer, chunk])
+ while (true) {
+ const headerEnd = buffer.indexOf('\r\n\r\n')
+ if (headerEnd < 0) {
+ return
+ }
+
+ const header = buffer.slice(0, headerEnd).toString('utf8')
+ const match = header.match(/content-length\s*:\s*(\d+)/i)
+ if (!match) {
+ buffer = Buffer.alloc(0)
+ return
+ }
+
+ const bodyLength = Number(match[1])
+ const frameEnd = headerEnd + 4 + bodyLength
+ if (buffer.length < frameEnd) {
+ return
+ }
+
+ const body = buffer.slice(headerEnd + 4, frameEnd).toString('utf8')
+ buffer = buffer.slice(frameEnd)
+
+ try {
+ const message = JSON.parse(body)
+ if (Array.isArray(message)) {
+ for (const item of message) {
+ handleWarningRequest(item)
+ }
+ continue
+ }
+ handleWarningRequest(message)
+ } catch {
+ // Ignore malformed frames and keep the warning server alive.
+ }
+ }
+ })
+
+ process.stdin.resume()
+}
+
+function resolveSpawnEnv() {
+ const registryOverride = process.env.REGISTRY_OVERRIDE?.trim()
+ if (!registryOverride) {
+ return process.env
+ }
+
+ return {
+ ...process.env,
+ npm_config_registry: registryOverride
+ }
+}
+
+function startConfiguredServer() {
+ const args = ['-y', LARK_MCP_PACKAGE, 'mcp', '-a', appId, '-s', appSecret]
+ if (brand === 'lark') {
+ args.push('--domain', 'https://open.larksuite.com')
+ }
+ if (preset) {
+ args.push('-t', preset)
+ }
+
+ const child = spawn('npx', args, {
+ stdio: 'inherit',
+ env: resolveSpawnEnv()
+ })
+
+ child.on('error', (error) => {
+ console.error(`Failed to launch Feishu MCP via npx: ${error.message}`)
+ process.exit(1)
+ })
+
+ child.on('exit', (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal)
+ return
+ }
+ process.exit(code ?? 0)
+ })
+}
+
+if (appId && appSecret) {
+ startConfiguredServer()
+} else {
+ startWarningServer()
+}
diff --git a/plugins/feishu/plugin.json b/plugins/feishu/plugin.json
new file mode 100644
index 000000000..8da49a279
--- /dev/null
+++ b/plugins/feishu/plugin.json
@@ -0,0 +1,44 @@
+{
+ "id": "com.deepchat.plugins.feishu",
+ "name": "Feishu/Lark Integration",
+ "version": "0.1.0",
+ "publisher": "DeepChat",
+ "engines": {
+ "deepchat": ">=${app.version}",
+ "platforms": ["darwin", "linux", "win32"]
+ },
+ "activationEvents": ["onEnable"],
+ "capabilities": ["runtime.manage", "mcp.register", "skills.register", "settings.contribute"],
+ "source": {
+ "type": "deepchat-official",
+ "url": "${github.release.download}/deepchat-plugin-feishu-${app.version}-${arch}.dcplugin",
+ "publisher": "DeepChat"
+ },
+ "mcpServers": [
+ {
+ "id": "feishu-tools",
+ "displayName": "Feishu/Lark Tools",
+ "transport": "stdio",
+ "command": "node",
+ "args": ["${plugin.root}/mcp/serve.mjs"],
+ "env": {},
+ "autoApprove": []
+ }
+ ],
+ "skills": [
+ {
+ "id": "feishu-tools",
+ "path": "skills/feishu-tools/SKILL.md",
+ "scope": "agent"
+ }
+ ],
+ "settingsContributions": [
+ {
+ "id": "feishu-settings",
+ "title": "Feishu/Lark Integration",
+ "placement": "plugins",
+ "entry": "settings/index.html",
+ "preloadTypes": "types/settings-preload.d.ts"
+ }
+ ]
+}
diff --git a/plugins/feishu/settings/assets/index.css b/plugins/feishu/settings/assets/index.css
new file mode 100644
index 000000000..569550afc
--- /dev/null
+++ b/plugins/feishu/settings/assets/index.css
@@ -0,0 +1,262 @@
+:root {
+ color-scheme: light dark;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ background: #f7f7f4;
+ color: #171717;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ background: #121212;
+ color: #f4f4f5;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+.shell {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-height: 100vh;
+ padding: 24px;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding-bottom: 4px;
+}
+
+.eyebrow {
+ margin: 0 0 6px;
+ color: #64748b;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+h1 {
+ margin: 0;
+ font-size: 22px;
+ line-height: 1.25;
+}
+
+h2.section-title {
+ margin: 0 0 8px;
+ font-size: 13px;
+ font-weight: 700;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0;
+}
+
+.state {
+ border: 1px solid #cbd5e1;
+ border-radius: 6px;
+ padding: 6px 10px;
+ font-size: 12px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.state-ok {
+ border-color: #16a34a;
+ color: #166534;
+}
+
+.state-muted {
+ color: #64748b;
+}
+
+.panel {
+ border: 1px solid #d7d7d0;
+ border-radius: 8px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.68);
+ padding: 14px 16px;
+}
+
+.row {
+ display: grid;
+ grid-template-columns: 170px minmax(0, 1fr);
+ gap: 16px;
+ align-items: center;
+ min-height: 44px;
+ padding: 10px 0;
+ border-top: 1px solid #e5e5df;
+}
+
+.row:first-child {
+ border-top: 0;
+}
+
+.row span {
+ color: #52525b;
+ font-size: 13px;
+}
+
+.row strong {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-size: 13px;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px 0;
+}
+
+.form-group + .form-group {
+ border-top: 1px solid #e5e5df;
+ padding-top: 14px;
+ margin-top: 2px;
+}
+
+.form-group label {
+ font-size: 13px;
+ font-weight: 600;
+ color: #52525b;
+}
+
+.form-group input,
+.form-group select {
+ height: 36px;
+ border: 1px solid #d7d7d0;
+ border-radius: 6px;
+ padding: 0 10px;
+ font: inherit;
+ font-size: 13px;
+ background: #fff;
+ color: inherit;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #2563eb;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding-top: 4px;
+}
+
+button {
+ min-height: 34px;
+ border: 1px solid #b8c0cc;
+ border-radius: 6px;
+ padding: 0 14px;
+ background: #ffffff;
+ color: #111827;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+button:hover {
+ border-color: #2563eb;
+}
+
+button.primary {
+ background: #2563eb;
+ border-color: #2563eb;
+ color: #fff;
+}
+
+button.primary:hover {
+ background: #1d4ed8;
+}
+
+button.danger:hover {
+ border-color: #dc2626;
+ color: #991b1b;
+}
+
+.message {
+ min-height: 20px;
+ margin: 0;
+ color: #475569;
+ font-size: 13px;
+}
+
+@media (prefers-color-scheme: dark) {
+ .panel {
+ border-color: #3f3f46;
+ background: rgba(24, 24, 27, 0.72);
+ }
+
+ .row {
+ border-color: #2f2f33;
+ }
+
+ .form-group {
+ border-color: #2f2f33;
+ }
+
+ .row span,
+ .message,
+ .eyebrow,
+ .form-group label {
+ color: #a1a1aa;
+ }
+
+ .form-group input,
+ .form-group select {
+ background: #18181b;
+ border-color: #3f3f46;
+ color: #fafafa;
+ }
+
+ button {
+ border-color: #52525b;
+ background: #18181b;
+ color: #fafafa;
+ }
+
+ button.primary {
+ background: #2563eb;
+ border-color: #2563eb;
+ color: #fff;
+ }
+
+ .state-ok {
+ color: #86efac;
+ }
+}
+
+.hint {
+ margin: 4px 0 0;
+ font-size: 12px;
+ color: #94a3b8;
+}
+
+.hint a {
+ color: #2563eb;
+ text-decoration: none;
+}
+
+.hint a:hover {
+ text-decoration: underline;
+}
diff --git a/plugins/feishu/settings/assets/index.js b/plugins/feishu/settings/assets/index.js
new file mode 100644
index 000000000..24d5b1d06
--- /dev/null
+++ b/plugins/feishu/settings/assets/index.js
@@ -0,0 +1,116 @@
+const stateNode = document.getElementById('plugin-state')
+const mcpStateNode = document.getElementById('mcp-state')
+const brandNode = document.getElementById('brand')
+const appIdNode = document.getElementById('app-id')
+const appSecretNode = document.getElementById('app-secret')
+const presetNode = document.getElementById('preset')
+const messageNode = document.getElementById('message')
+
+function setText(node, value) {
+ if (node) node.textContent = value || 'Unknown'
+}
+
+function setMessage(value) {
+ if (messageNode) messageNode.textContent = value || ''
+}
+
+function setState(enabled) {
+ if (!stateNode) return
+ stateNode.textContent = enabled ? 'Enabled' : 'Disabled'
+ stateNode.className = enabled ? 'state state-ok' : 'state state-muted'
+}
+
+function getPluginApi() {
+ const api = window.deepchatPlugin
+ if (!api) throw new Error('Plugin settings bridge is unavailable.')
+ return api
+}
+
+async function loadConfig() {
+ const result = await getPluginApi().invokeAction('config.get')
+ if (result.ok && result.data) {
+ brandNode.value = result.data.brand || 'feishu'
+ appIdNode.value = result.data.appId || ''
+ appSecretNode.value = result.data.appSecret || ''
+ presetNode.value = result.data.preset || 'preset.default'
+ }
+}
+
+async function refreshStatus() {
+ const status = await getPluginApi().getStatus()
+ setState(status.enabled)
+
+ const mcp = status.mcpServers?.find((s) => s.serverId === 'feishu-tools')
+ if (!mcp) {
+ setText(mcpStateNode, 'Unavailable')
+ } else if (mcp.running) {
+ setText(mcpStateNode, 'Running')
+ setMessage('')
+ } else if (mcp.enabled) {
+ setText(mcpStateNode, 'Stopped')
+ setMessage('')
+ } else if (mcp.lastError) {
+ setText(mcpStateNode, 'Error')
+ setMessage(mcp.lastError)
+ } else {
+ setText(mcpStateNode, 'Disabled')
+ setMessage('')
+ }
+
+ if (!mcp) {
+ setMessage('')
+ }
+}
+
+document.getElementById('save')?.addEventListener('click', async () => {
+ const appId = appIdNode.value.trim()
+ const appSecret = appSecretNode.value.trim()
+
+ if (!appId || !appSecret) {
+ setMessage('App ID and App Secret are required.')
+ return
+ }
+
+ setMessage('Saving...')
+ const result = await getPluginApi().invokeAction('config.set', {
+ appId,
+ appSecret,
+ brand: brandNode.value,
+ preset: presetNode.value
+ })
+
+ if (!result.ok) {
+ setMessage(result.error || 'Failed to save config.')
+ return
+ }
+
+ setMessage('Saved. Restart the MCP server to apply changes.')
+})
+
+document.getElementById('disable')?.addEventListener('click', async () => {
+ try {
+ const result = await getPluginApi().disable()
+ if (!result.ok) {
+ setMessage(result.error || 'Failed to disable plugin.')
+ return
+ }
+ await refreshStatus()
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : String(error))
+ }
+})
+
+document.getElementById('preset-docs')?.addEventListener('click', async (e) => {
+ e.preventDefault()
+ try {
+ await getPluginApi().invokeAction('shell.openExternal', {
+ url: 'https://github.com/larksuite/lark-openapi-mcp/blob/main/docs/reference/tool-presets/presets.md'
+ })
+ } catch {
+ // ignore
+ }
+})
+
+Promise.all([loadConfig(), refreshStatus()]).catch((error) => {
+ setMessage(error instanceof Error ? error.message : String(error))
+})
diff --git a/plugins/feishu/settings/index.html b/plugins/feishu/settings/index.html
new file mode 100644
index 000000000..ae675fd23
--- /dev/null
+++ b/plugins/feishu/settings/index.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+ Feishu/Lark Integration
+
+
+
+
+
+
+
+
+
+
+
+
+ MCP Server
+ Unknown
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/feishu/skills/feishu-tools/SKILL.md b/plugins/feishu/skills/feishu-tools/SKILL.md
new file mode 100644
index 000000000..cbd29c0bd
--- /dev/null
+++ b/plugins/feishu/skills/feishu-tools/SKILL.md
@@ -0,0 +1,54 @@
+---
+name: feishu-tools
+description: Use the Feishu/Lark plugin MCP tools for Feishu documents, spreadsheets, knowledge content, and other matching workspace operations.
+metadata:
+ deepchatFeature: feishu-integration
+---
+
+# feishu-tools
+
+This plugin is an MCP server tool surface exposed by DeepChat's Feishu plugin. Do not ask the user to classify the plugin as an MCP server, a CLI tool, or another plugin type. When a request is about Feishu/Lark content and matching tools are available, invoke the relevant tool directly.
+
+## Runtime Context
+
+- Plugin id: `${OWNER_PLUGIN_ID}`.
+- Plugin root: `${PLUGIN_ROOT}`.
+- Server id: `feishu-tools`.
+
+## When To Use
+
+- The user asks to read, summarize, search, create, update, append to, or organize Feishu/Lark
+ documents.
+- The user asks to inspect or edit Feishu/Lark spreadsheets, sheets, tables, or similar structured
+ workspace data.
+- The user asks to operate on another Feishu/Lark artifact and the current tool list exposes a
+ matching tool by name or description.
+
+## Required Behavior
+
+1. Treat the currently exposed `feishu-tools` MCP tools as the primary action surface for Feishu/Lark requests.
+2. Use the live tool names and descriptions in the current session as the source of truth for what
+ the server supports.
+3. Prefer the matching tool directly instead of asking the user how to call the plugin or what kind
+ of plugin it is.
+4. When the user provides a Feishu/Lark URL, extract the relevant document, sheet, spreadsheet, or
+ workspace identifier when the target tool expects an id or token.
+5. For write operations that could overwrite or append content, confirm intent only when the target
+ artifact or requested mutation is ambiguous or destructive.
+6. If the requested operation has no matching currently exposed tool, explain that the active
+ Feishu preset may not include it and describe the gap.
+7. If a tool call returns an authentication or configuration error, tell the user to open the
+ Feishu plugin settings and verify App ID, App Secret, brand, and preset.
+
+## Routing Hints
+
+- For documents, prefer tools whose names or descriptions reference docs, docx, wiki, or knowledge.
+- For spreadsheets or tables, prefer tools whose names or descriptions reference sheets,
+ spreadsheets, tables, or bitable-like structures.
+- For task, calendar, or IM requests, prefer the matching domain-specific Feishu/Lark tools when
+ they are exposed by the current preset.
+
+## Important Constraint
+
+Tool availability depends on the current Feishu preset. The skill should guide tool choice, not
+invent unsupported tool names.
diff --git a/plugins/feishu/types/settings-preload.d.ts b/plugins/feishu/types/settings-preload.d.ts
new file mode 100644
index 000000000..f65af8142
--- /dev/null
+++ b/plugins/feishu/types/settings-preload.d.ts
@@ -0,0 +1,19 @@
+interface DeepChatPluginSettingsApi {
+ getPluginId(): string
+ getStatus(): Promise<{
+ pluginId: string
+ enabled: boolean
+ runtime?: import('../../../src/shared/types/plugin').PluginRuntimeStatus
+ mcpServers?: import('../../../src/shared/types/plugin').PluginMcpRuntimeStatus[]
+ }>
+ enable(): Promise
+ disable(): Promise
+ invokeAction(
+ actionId: string,
+ payload?: unknown
+ ): Promise
+}
+
+interface Window {
+ deepchatPlugin?: DeepChatPluginSettingsApi
+}
diff --git a/src/main/presenter/pluginPresenter/index.ts b/src/main/presenter/pluginPresenter/index.ts
index c6711f876..690c17b90 100644
--- a/src/main/presenter/pluginPresenter/index.ts
+++ b/src/main/presenter/pluginPresenter/index.ts
@@ -218,6 +218,22 @@ export class PluginPresenter {
error:
'Helper uninstall is not implemented for this runtime. Use the helper provider uninstall flow.'
}
+ case 'config.get': {
+ const plugin = this.getInstalledOrOfficialPluginOrThrow(pluginId)
+ const configPath = path.join(plugin.root, 'config.json')
+ if (!fs.existsSync(configPath)) {
+ return { ok: true, data: {} }
+ }
+ const raw = fs.readFileSync(configPath, 'utf-8')
+ return { ok: true, data: JSON.parse(raw) }
+ }
+ case 'config.set': {
+ const plugin = this.getInstalledOrOfficialPluginOrThrow(pluginId)
+ const payload = (_payload ?? {}) as Record
+ const configPath = path.join(plugin.root, 'config.json')
+ fs.writeFileSync(configPath, JSON.stringify(payload, null, 2), 'utf-8')
+ return { ok: true }
+ }
default:
throw new Error(`Unsupported plugin action: ${actionId}`)
}
@@ -239,18 +255,21 @@ export class PluginPresenter {
await this.disableByOwner(pluginId)
- const runtime = await this.refreshRuntime(pluginId)
- this.upsertResource({
- pluginId,
- kind: 'runtime',
- key: runtime.runtimeId,
- payload: this.toJsonPayload(runtime),
- enabled: true
- })
+ let runtime: PluginRuntimeStatus | undefined
+ if (plugin.manifest.runtime) {
+ runtime = await this.refreshRuntime(pluginId)
+ this.upsertResource({
+ pluginId,
+ kind: 'runtime',
+ key: runtime.runtimeId,
+ payload: this.toJsonPayload(runtime),
+ enabled: true
+ })
+ }
this.registerSettingsContributions(plugin)
- if (runtime.state !== 'installed' && runtime.state !== 'running') {
+ if (runtime && runtime.state !== 'installed' && runtime.state !== 'running') {
return
}
@@ -288,9 +307,15 @@ export class PluginPresenter {
this.removeResourceRecordsByOwner(pluginId)
}
+ private async removePersistedInstallation(pluginId: string): Promise {
+ await this.disableByOwner(pluginId)
+ this.removeInstallationRecord(pluginId)
+ this.removeRuntimeRecordsByOwner(pluginId)
+ }
+
private async registerMcpServers(
plugin: ResolvedOfficialPlugin,
- runtime: PluginRuntimeStatus
+ runtime?: PluginRuntimeStatus
): Promise {
const servers = plugin.manifest.mcpServers ?? []
const registeredServerNames: string[] = []
@@ -394,10 +419,6 @@ export class PluginPresenter {
private async openPluginSettingsWindow(pluginId: string): Promise {
const plugin = this.getInstalledOrOfficialPluginOrThrow(pluginId)
- const installation = this.getInstallation(pluginId)
- if (!installation?.enabled) {
- throw new Error(`Plugin ${pluginId} is not enabled`)
- }
const settings = this.getSettingsContribution(pluginId)
if (!settings) {
@@ -737,15 +758,30 @@ export class PluginPresenter {
continue
}
if (!this.isPluginPlatformSupported(plugin.manifest)) {
+ console.info(`[PluginHost] Skipping plugin ${plugin.manifest.id}: platform not supported`)
+ await this.removePersistedInstallation(plugin.manifest.id)
continue
}
- this.assertTrustedOfficialPlugin(plugin.manifest)
+ try {
+ this.assertTrustedOfficialPlugin(plugin.manifest)
+ } catch (error) {
+ console.warn(`[PluginHost] Skipping untrusted plugin ${plugin.manifest.id}:`, error)
+ await this.removePersistedInstallation(plugin.manifest.id)
+ continue
+ }
+ console.info(`[PluginHost] Discovered plugin: ${plugin.manifest.id} at ${plugin.root}`)
this.officialPlugins.set(plugin.manifest.id, plugin)
}
}
private resolveOfficialPluginDirectories(): ResolvedOfficialPlugin[] {
- const sourceRoots = [this.getPluginInstallRoot()]
+ const sourceRoots = this.isPackaged
+ ? [this.getPluginInstallRoot()]
+ : [
+ path.join(process.cwd(), 'plugins'),
+ path.join(this.appPath, 'plugins'),
+ this.getPluginInstallRoot()
+ ]
const pluginRoots = new Set()
for (const sourceRoot of sourceRoots) {
@@ -906,7 +942,14 @@ export class PluginPresenter {
: undefined
if (existing && existingManifestPath && fs.existsSync(existingManifestPath)) {
const existingManifest = this.readManifest(existingManifestPath)
- if (existingManifest.version === plugin.manifest.version) {
+ const shouldRefreshDirectoryInstallation =
+ plugin.sourceType === 'directory' &&
+ path.resolve(plugin.sourcePath) !== path.resolve(existing.path)
+ if (
+ !shouldRefreshDirectoryInstallation &&
+ existingManifest.version === plugin.manifest.version &&
+ this.arePluginManifestsEquivalent(existingManifest, plugin.manifest)
+ ) {
this.assertTrustedOfficialPlugin(existingManifest)
this.assertPlatformSupported(existingManifest)
this.applyDeclaredExecutablePermissions(existingManifest, existing.path)
@@ -959,6 +1002,7 @@ export class PluginPresenter {
return installRoot
}
+ const preservedConfig = this.readInstalledPluginConfig(installRoot)
fs.rmSync(installRoot, { recursive: true, force: true })
fs.mkdirSync(installRoot, { recursive: true })
@@ -968,9 +1012,33 @@ export class PluginPresenter {
this.copyPluginDirectory(plugin.sourcePath, installRoot)
}
+ this.writeInstalledPluginConfig(installRoot, preservedConfig)
+
return installRoot
}
+ private arePluginManifestsEquivalent(
+ left: DeepChatPluginManifest,
+ right: DeepChatPluginManifest
+ ): boolean {
+ return JSON.stringify(left) === JSON.stringify(right)
+ }
+
+ private readInstalledPluginConfig(installRoot: string): string | undefined {
+ const configPath = path.join(installRoot, 'config.json')
+ if (!fs.existsSync(configPath) || !fs.statSync(configPath).isFile()) {
+ return undefined
+ }
+ return fs.readFileSync(configPath, 'utf8')
+ }
+
+ private writeInstalledPluginConfig(installRoot: string, config: string | undefined): void {
+ if (config === undefined) {
+ return
+ }
+ fs.writeFileSync(path.join(installRoot, 'config.json'), config, 'utf8')
+ }
+
private extractPluginPackage(packagePath: string, installRoot: string): void {
const files = this.readPluginPackage(packagePath)
for (const [relativePath, content] of Object.entries(files)) {
@@ -1096,6 +1164,20 @@ export class PluginPresenter {
}
private getInstalledOrOfficialPluginOrThrow(pluginId: string): ResolvedOfficialPlugin {
+ const official = this.officialPlugins.get(pluginId)
+ if (official) {
+ const installation = this.ensureOfficialPluginInstallation(official)
+ const manifestPath = path.join(installation.path, 'plugin.json')
+ if (fs.existsSync(manifestPath)) {
+ return {
+ manifest: this.readManifest(manifestPath),
+ root: installation.path,
+ sourcePath: installation.path,
+ sourceType: 'directory'
+ }
+ }
+ }
+
const installation = this.getInstallation(pluginId)
if (installation?.path && fs.existsSync(path.join(installation.path, 'plugin.json'))) {
return {
@@ -1117,6 +1199,13 @@ export class PluginPresenter {
return this.getInstallations().find((installation) => installation.pluginId === pluginId)
}
+ private removeInstallationRecord(pluginId: string): void {
+ this.store.set(
+ 'installations',
+ this.getInstallations().filter((installation) => installation.pluginId !== pluginId)
+ )
+ }
+
private upsertInstallation(record: PluginInstallationRecord): void {
this.store.set('installations', [
...this.getInstallations().filter((item) => item.pluginId !== record.pluginId),
@@ -1173,6 +1262,13 @@ export class PluginPresenter {
)
}
+ private removeRuntimeRecordsByOwner(pluginId: string): void {
+ this.store.set(
+ 'runtimes',
+ (this.store.get('runtimes') ?? []).filter((runtime) => runtime.pluginId !== pluginId)
+ )
+ }
+
private upsertRuntimeRecord(record: RuntimeDependencyRecord): void {
this.store.set('runtimes', [
...(this.store.get('runtimes') ?? []).filter(
@@ -1183,24 +1279,85 @@ export class PluginPresenter {
])
}
+ private resolveManifestSettingsContribution(
+ plugin: ResolvedOfficialPlugin,
+ pluginRoot: string
+ ): PluginSettingsContribution | undefined {
+ const contribution = plugin.manifest.settingsContributions?.[0]
+ if (!contribution) {
+ return undefined
+ }
+
+ const entry = this.resolvePluginRelativePath(pluginRoot, contribution.entry)
+ const preloadTypes = this.resolvePluginRelativePath(pluginRoot, contribution.preloadTypes)
+ if (!fs.existsSync(entry) || !fs.existsSync(preloadTypes)) {
+ return undefined
+ }
+
+ return {
+ id: contribution.id,
+ ownerPluginId: plugin.manifest.id,
+ title: contribution.title,
+ placement: contribution.placement,
+ entry,
+ preloadTypes
+ }
+ }
+
+ private isSettingsContributionAvailable(settings?: PluginSettingsContribution): boolean {
+ try {
+ const entry = settings?.entry
+ const preloadTypes = settings?.preloadTypes
+ if (!entry || !preloadTypes) {
+ return false
+ }
+ return fs.existsSync(entry) && fs.existsSync(preloadTypes)
+ } catch {
+ return false
+ }
+ }
+
private getSettingsContribution(pluginId: string): PluginSettingsContribution | undefined {
const record = this.getResources().find(
(resource) =>
resource.pluginId === pluginId && resource.kind === 'settings' && resource.enabled
)
- return record?.payload as unknown as PluginSettingsContribution | undefined
+ const stored = record?.payload as unknown as PluginSettingsContribution | undefined
+ if (this.isSettingsContributionAvailable(stored)) {
+ return stored
+ }
+
+ const plugin = this.getOfficialPluginOrThrow(pluginId)
+ const installation = this.getInstallation(pluginId)
+ if (installation?.path) {
+ const installedSettings = this.resolveManifestSettingsContribution(plugin, installation.path)
+ if (installedSettings) {
+ return installedSettings
+ }
+ }
+
+ if (plugin.sourceType === 'package') {
+ const ensuredInstallation = this.ensureOfficialPluginInstallation(plugin)
+ return this.resolveManifestSettingsContribution(plugin, ensuredInstallation.path)
+ }
+
+ return this.resolveManifestSettingsContribution(plugin, plugin.root)
}
private resolvePluginTemplate(
template: string,
plugin: ResolvedOfficialPlugin,
- runtime: PluginRuntimeStatus
+ runtime?: PluginRuntimeStatus
): string {
- return template
- .replaceAll(`\${runtime.${runtime.runtimeId}.command}`, runtime.command ?? '')
- .replaceAll(`\${runtime.${runtime.runtimeId}.helperAppPath}`, runtime.helperAppPath ?? '')
+ let result = template
.replaceAll('${plugin.root}', plugin.root)
.replaceAll('${plugin.id}', plugin.manifest.id)
+ if (runtime) {
+ result = result
+ .replaceAll(`\${runtime.${runtime.runtimeId}.command}`, runtime.command ?? '')
+ .replaceAll(`\${runtime.${runtime.runtimeId}.helperAppPath}`, runtime.helperAppPath ?? '')
+ }
+ return result
}
private resolveRuntimeCandidate(candidate: string, pluginRoot: string): string | null {
@@ -1220,7 +1377,7 @@ export class PluginPresenter {
private resolvePluginTemplateRecord(
input: Record,
plugin: ResolvedOfficialPlugin,
- runtime: PluginRuntimeStatus
+ runtime?: PluginRuntimeStatus
): Record {
return Object.fromEntries(
Object.entries(input).map(([key, value]) => [
diff --git a/src/renderer/settings/components/PluginsSettings.vue b/src/renderer/settings/components/PluginsSettings.vue
index 0cd9002c5..16575e459 100644
--- a/src/renderer/settings/components/PluginsSettings.vue
+++ b/src/renderer/settings/components/PluginsSettings.vue
@@ -101,6 +101,7 @@