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 + + + +
+
+
+

Official Plugin

+

Feishu/Lark Integration

+
+ Loading +
+ +
+

Credentials

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

MCP Preset

+
+ + +

+ Controls which API tools are available. + View preset details +

+
+
+ +
+
+ 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 @@
' +}) + +const pluginClient = { + listPlugins: vi.fn(), + enablePlugin: vi.fn(), + disablePlugin: vi.fn(), + invokeAction: vi.fn() +} + +vi.mock('@api/PluginClient', () => ({ + createPluginClient: () => pluginClient +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +describe('PluginsSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + pluginClient.listPlugins.mockResolvedValue([ + { + id: 'com.deepchat.plugins.feishu', + name: 'Feishu/Lark Integration', + version: '0.1.0', + publisher: 'DeepChat', + installed: true, + enabled: false, + trusted: true, + trustState: 'trusted', + official: true, + capabilities: ['mcp.register', 'settings.contribute'], + mcpServers: [], + settings: { + id: 'feishu-settings', + ownerPluginId: 'com.deepchat.plugins.feishu', + title: 'Feishu/Lark Integration', + placement: 'plugins', + entry: '/mock/settings/index.html', + preloadTypes: '/mock/settings-preload.d.ts' + } + } + ]) + pluginClient.enablePlugin.mockResolvedValue({ ok: true }) + pluginClient.disablePlugin.mockResolvedValue({ ok: true }) + pluginClient.invokeAction.mockResolvedValue({ ok: true }) + }) + + it('shows the settings action for a disabled plugin with a settings contribution', async () => { + const PluginsSettings = ( + await import('../../../src/renderer/settings/components/PluginsSettings.vue') + ).default + + const wrapper = mount(PluginsSettings, { + global: { + stubs: { + Button: buttonStub, + Icon: true + } + } + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="plugin-enable-com.deepchat.plugins.feishu"]').exists()).toBe( + true + ) + expect( + wrapper.find('[data-testid="plugin-settings-com.deepchat.plugins.feishu"]').exists() + ).toBe(true) + }) + + it('opens plugin settings without enabling the plugin first', async () => { + const PluginsSettings = ( + await import('../../../src/renderer/settings/components/PluginsSettings.vue') + ).default + + const wrapper = mount(PluginsSettings, { + global: { + stubs: { + Button: buttonStub, + Icon: true + } + } + }) + + await flushPromises() + await wrapper + .find('[data-testid="plugin-settings-com.deepchat.plugins.feishu"]') + .trigger('click') + await flushPromises() + + expect(pluginClient.invokeAction).toHaveBeenCalledWith({ + pluginId: 'com.deepchat.plugins.feishu', + actionId: 'settings.open' + }) + }) +}) diff --git a/test/renderer/plugins/feishuSettings.test.ts b/test/renderer/plugins/feishuSettings.test.ts new file mode 100644 index 000000000..b4bedfea1 --- /dev/null +++ b/test/renderer/plugins/feishuSettings.test.ts @@ -0,0 +1,133 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const scriptPath = resolve(process.cwd(), 'plugins/feishu/settings/assets/index.js') + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +const renderSettingsDom = (): void => { + document.body.innerHTML = ` + + + + + + +

+ + + + ` +} + +type FeishuSettingsWindow = Window & { deepchatPlugin?: unknown } + +const runSettingsScript = async (): Promise => { + const script = await readFile(scriptPath, 'utf8') + window.eval(`(() => {\n${script}\n})()`) +} + +describe('Feishu plugin settings', () => { + beforeEach(() => { + renderSettingsDom() + delete (window as FeishuSettingsWindow).deepchatPlugin + }) + + it.each([ + [ + 'when the MCP server is unavailable', + { + enabled: true, + mcpServers: [] + }, + 'Unavailable' + ], + [ + 'when the MCP server is running', + { + enabled: true, + mcpServers: [ + { + serverId: 'feishu-tools', + enabled: true, + running: true, + lastError: 'stale failure' + } + ] + }, + 'Running' + ], + [ + 'when the MCP server is stopped but still enabled', + { + enabled: true, + mcpServers: [ + { + serverId: 'feishu-tools', + enabled: true, + running: false + } + ] + }, + 'Stopped' + ], + [ + 'when the MCP server is disabled without an error', + { + enabled: true, + mcpServers: [ + { + serverId: 'feishu-tools', + enabled: false, + running: false + } + ] + }, + 'Disabled' + ] + ])('clears stale MCP errors %s', async (_label, status, expectedState) => { + const pluginWindow = window as FeishuSettingsWindow + + document.getElementById('message')!.textContent = 'stale failure' + pluginWindow.deepchatPlugin = { + getStatus: vi.fn().mockResolvedValue(status), + invokeAction: vi.fn().mockResolvedValue({ ok: true, data: {} }), + disable: vi.fn() + } + + await runSettingsScript() + await flushPromises() + + expect(document.getElementById('mcp-state')?.textContent).toBe(expectedState) + expect(document.getElementById('message')?.textContent).toBe('') + }) + + it('shows the latest MCP error when the server reports one', async () => { + const pluginWindow = window as FeishuSettingsWindow + + pluginWindow.deepchatPlugin = { + getStatus: vi.fn().mockResolvedValue({ + enabled: true, + mcpServers: [ + { + serverId: 'feishu-tools', + enabled: false, + running: false, + lastError: 'connect failed' + } + ] + }), + invokeAction: vi.fn().mockResolvedValue({ ok: true, data: {} }), + disable: vi.fn() + } + + await runSettingsScript() + await flushPromises() + + expect(document.getElementById('mcp-state')?.textContent).toBe('Error') + expect(document.getElementById('message')?.textContent).toBe('connect failed') + }) +})