mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-02 05:54:33 +08:00
feat(plugin): agent tools served by plugins, with result views (P2 batch 8) (#3733)
* feat(plugin): agent tools served by plugins, with result views * fix(plugin): expand plugin tool views in place in the agent stream
This commit is contained in:
@@ -7,6 +7,8 @@ related work and owners among the synced issues.
|
||||
It is the fullest example plugin. It shows:
|
||||
|
||||
- a connector with incremental sync, checkpoints and deletions;
|
||||
- agent tools the plugin serves itself (`search_issues`, `get_issue`), with
|
||||
result views: a table of issues, and the issue as Markdown;
|
||||
- an OAuth field (`x-oauth`): WeKnora runs Atlassian's consent flow and hands
|
||||
the plugin a fresh access token at every call;
|
||||
- dynamic choices (`x-options`): the sites the account can reach and the
|
||||
@@ -28,8 +30,11 @@ It is the fullest example plugin. It shows:
|
||||
`<WeKnora address>/api/v1/plugin-oauth/callback`.
|
||||
4. In the plugin's details in WeKnora, enter the app's client ID and
|
||||
secret.
|
||||
3. Workspace admins switch the plugin on under **Settings → Plugins**, then
|
||||
add a **Jira** data source to a knowledge base.
|
||||
3. Workspace admins switch the plugin on under **Settings → Plugins**.
|
||||
- To sync, add a **Jira** data source to a knowledge base.
|
||||
- For the agent tools, fill in the plugin's configuration there (an
|
||||
Atlassian account or an API token). The tools then appear as the
|
||||
**Jira** MCP service, ready to add to agents.
|
||||
|
||||
## What is synced
|
||||
|
||||
|
||||
@@ -173,6 +173,8 @@ func statusError(resp *http.Response, body []byte) error {
|
||||
return e
|
||||
case resp.StatusCode >= 500:
|
||||
return pluginapi.Errorf(pluginapi.CodeUnavailable, "jira %d: %s", resp.StatusCode, msg)
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return pluginapi.Errorf(pluginapi.CodeNotFound, "jira 404: %s", msg)
|
||||
case resp.StatusCode == http.StatusBadRequest:
|
||||
// Mostly a JQL filter Jira does not accept.
|
||||
return pluginapi.InvalidConfig("jira rejected the query: "+msg,
|
||||
@@ -325,6 +327,21 @@ var searchFields = []string{
|
||||
"labels", "created", "updated", "project", "parent", "comment",
|
||||
}
|
||||
|
||||
// searchPage runs JQL for at most limit issues, without comments, and says
|
||||
// whether more matched.
|
||||
func (c *client) searchPage(ctx context.Context, jql string, limit int) ([]issue, bool, error) {
|
||||
body := map[string]any{"jql": jql, "maxResults": limit, "fields": searchFields[:len(searchFields)-1]}
|
||||
var page struct {
|
||||
Issues []issue `json:"issues"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
IsLast bool `json:"isLast"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/rest/api/3/search/jql", body, &page); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return page.Issues, !page.IsLast && page.NextPageToken != "", nil
|
||||
}
|
||||
|
||||
// search runs JQL a page at a time (the enhanced search API, which pages
|
||||
// with a token). fn sees each page; returning an error stops the search.
|
||||
func (c *client) search(ctx context.Context, jql string, withComments bool, fn func([]issue) error) error {
|
||||
|
||||
@@ -66,6 +66,18 @@ func newFakeJira(t *testing.T) *fakeJira {
|
||||
writeJSON(w, []issueType{{Name: "Task"}, {Name: "Bug"}, {Name: "Bug"}, {Name: "Sub-task", Subtask: true}})
|
||||
})
|
||||
api.HandleFunc("POST /rest/api/3/search/jql", f.search)
|
||||
api.HandleFunc("GET /rest/api/3/issue/{key}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, is := range f.issues {
|
||||
if is["key"] == r.PathValue("key") {
|
||||
writeJSON(w, is)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, `{"errorMessages":["Issue does not exist or you do not have permission to see it."]}`,
|
||||
http.StatusNotFound)
|
||||
})
|
||||
mux.Handle("/ex/jira/"+cloudID+"/", http.StripPrefix("/ex/jira/"+cloudID, bearer(api)))
|
||||
mux.Handle("/rest/", basic(api))
|
||||
f.Server = httptest.NewServer(mux)
|
||||
@@ -122,7 +134,10 @@ func (f *fakeJira) search(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.jql = append(f.jql, in.JQL)
|
||||
key := projectClause.FindStringSubmatch(in.JQL)[1]
|
||||
key := ""
|
||||
if m := projectClause.FindStringSubmatch(in.JQL); m != nil {
|
||||
key = m[1]
|
||||
}
|
||||
var since time.Time
|
||||
if m := updatedClause.FindStringSubmatch(in.JQL); m != nil {
|
||||
since, _ = time.ParseInLocation("2006/01/02 15:04", m[1], shanghai)
|
||||
@@ -131,7 +146,7 @@ func (f *fakeJira) search(w http.ResponseWriter, r *http.Request) {
|
||||
for _, is := range f.issues {
|
||||
fields := is["fields"].(map[string]any)
|
||||
updated, _ := time.Parse("2006-01-02T15:04:05.000-0700", fields["updated"].(string))
|
||||
if strings.HasPrefix(is["key"].(string), key+"-") && !updated.Before(since) {
|
||||
if strings.HasPrefix(is["key"].(string), key) && !updated.Before(since) {
|
||||
list = append(list, is)
|
||||
}
|
||||
}
|
||||
@@ -502,3 +517,73 @@ func TestPluginPassesConformance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTools(t *testing.T) {
|
||||
f := newFakeJira(t)
|
||||
f.put("101", "ENG-1", "Login fails", "2026-09-20T10:00:00.000+0800")
|
||||
f.put("102", "ENG-2", "Login is slow", "2026-09-21T10:00:00.000+0800")
|
||||
f.put("103", "ENG-3", "Other", "2026-09-22T10:00:00.000+0800")
|
||||
r := newRun(t)
|
||||
tenant := map[string]any{"auth": "token", "base_url": f.URL, "email": email, "api_token": apiToken}
|
||||
call := func(tool string, args string, tenant map[string]any, locale string) pluginapi.ToolResult {
|
||||
t.Helper()
|
||||
req := pluginapi.MCPRequest{
|
||||
JSONRPC: "2.0", ID: json.RawMessage(`1`), Method: "tools/call",
|
||||
Params: json.RawMessage(`{"name":"` + tool + `","arguments":` + args + `}`),
|
||||
}
|
||||
var out pluginapi.MCPResponse
|
||||
env := pluginapi.Envelope{Context: pluginapi.Context{Locale: locale}, Config: pluginapi.Config{Tenant: tenant}}
|
||||
if err := r.client.Call(context.Background(), pluginapi.MCPPath(toolsServer), env, req, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res pluginapi.ToolResult
|
||||
if out.Error != nil || json.Unmarshal(out.Result, &res) != nil {
|
||||
t.Fatalf("%s: %+v", tool, out)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
res := call("search_issues", `{"text":"login","project":"ENG","limit":1}`, tenant, "")
|
||||
if f.lastJQL() != `project = "ENG" AND text ~ "login" ORDER BY updated DESC` || res.IsError {
|
||||
t.Fatalf("jql = %s, %+v", f.lastJQL(), res)
|
||||
}
|
||||
rows, _ := json.Marshal(res.StructuredContent)
|
||||
if !strings.Contains(string(rows), `"key":"ENG-1"`) || !strings.Contains(string(rows), `"more":true`) ||
|
||||
!strings.Contains(res.Content[0].Text, "- ENG-1 [In Progress] Login fails (unassigned") {
|
||||
t.Fatalf("search = %s\n%s", rows, res.Content[0].Text)
|
||||
}
|
||||
call("search_issues", `{"jql":"assignee = currentUser()"}`, tenant, "")
|
||||
if f.lastJQL() != "assignee = currentUser() ORDER BY updated DESC" {
|
||||
t.Fatalf("jql = %s", f.lastJQL())
|
||||
}
|
||||
if res := call("search_issues", `{}`, tenant, ""); !res.IsError {
|
||||
t.Fatal("a search needs jql or text")
|
||||
}
|
||||
|
||||
res = call("get_issue", `{"key":"eng-2"}`, tenant, "")
|
||||
md, _ := json.Marshal(res.StructuredContent)
|
||||
if res.IsError || !strings.HasPrefix(res.Content[0].Text, "# ENG-2: Login is slow") ||
|
||||
!strings.Contains(string(md), `"url":"`+f.URL+`/browse/ENG-2"`) {
|
||||
t.Fatalf("get_issue = %+v", res)
|
||||
}
|
||||
if res := call("get_issue", `{"key":"ENG-9"}`, tenant, ""); !res.IsError ||
|
||||
!strings.Contains(res.Content[0].Text, "does not exist") {
|
||||
t.Fatalf("missing issue = %+v", res)
|
||||
}
|
||||
if res := call("get_issue", `{"key":"DROP TABLE"}`, tenant, ""); !res.IsError {
|
||||
t.Fatal("a malformed key reaches Jira")
|
||||
}
|
||||
res = call("search_issues", `{"text":"x"}`, map[string]any{"auth": "oauth"}, "zh-CN")
|
||||
if !res.IsError || !strings.Contains(res.Content[0].Text, "本空间尚未连接 Jira") {
|
||||
t.Fatalf("unconfigured = %+v", res)
|
||||
}
|
||||
|
||||
// The workspace form offers sites and issue types too.
|
||||
var sites pluginapi.OptionsOutput
|
||||
err := r.client.Call(context.Background(), pluginapi.OptionsPath("sites"),
|
||||
pluginapi.Envelope{Config: pluginapi.Config{Tenant: map[string]any{"auth": "oauth", "account": oauthToken}}},
|
||||
pluginapi.OptionsInput{Field: "site", Scope: pluginapi.OptionsScopeTenant}, &sites)
|
||||
if err != nil || len(sites.Options) != 1 || sites.Options[0].Value != cloudID {
|
||||
t.Fatalf("tenant sites = %+v, %v", sites, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ func newPlugin() *pluginsdk.Plugin {
|
||||
p.Connector("jira", connector{})
|
||||
p.Options("sites", siteOptions)
|
||||
p.Options("issue_types", issueTypeOptions)
|
||||
registerTools(p)
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -289,6 +290,31 @@ func quote(s string) string {
|
||||
|
||||
// item turns an issue into a Markdown document.
|
||||
func (c *client) item(is *issue, projectKey string) pluginapi.FetchedItem {
|
||||
f := &is.Fields
|
||||
url := c.issueURL(is.Key)
|
||||
created, updated := f.Created.Time, f.Updated.Time
|
||||
return pluginapi.FetchedItem{
|
||||
ExternalID: is.ID,
|
||||
Title: is.Key + " " + f.Summary,
|
||||
Content: []byte(c.markdown(is)),
|
||||
ContentType: "text/markdown",
|
||||
FileName: fileName(is.Key + " " + f.Summary),
|
||||
URL: url,
|
||||
CreatedAt: nonZero(created),
|
||||
UpdatedAt: nonZero(updated),
|
||||
SourceResourceID: projectKey,
|
||||
Metadata: map[string]string{
|
||||
"channel": "jira", "jira_key": is.Key, "project": projectKey, "status": nameOf(f.Status),
|
||||
"issue_type": nameOf(f.IssueType), "assignee": userOf(f.Assignee), "priority": nameOf(f.Priority),
|
||||
"labels": strings.Join(f.Labels, ","),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) issueURL(key string) string { return c.siteURL + "/browse/" + key }
|
||||
|
||||
// markdown renders an issue: its facts, description and comments.
|
||||
func (c *client) markdown(is *issue) string {
|
||||
f := &is.Fields
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s: %s\n\n", is.Key, f.Summary)
|
||||
@@ -311,8 +337,7 @@ func (c *client) item(is *issue, projectKey string) pluginapi.FetchedItem {
|
||||
fmt.Fprintf(&b, "- **%s:** %s\n", kv[0], kv[1])
|
||||
}
|
||||
}
|
||||
url := c.siteURL + "/browse/" + is.Key
|
||||
fmt.Fprintf(&b, "- **Link:** %s\n", url)
|
||||
fmt.Fprintf(&b, "- **Link:** %s\n", c.issueURL(is.Key))
|
||||
if desc := adfToMarkdown(f.Description); desc != "" {
|
||||
b.WriteString("\n## Description\n\n" + desc + "\n")
|
||||
}
|
||||
@@ -325,23 +350,7 @@ func (c *client) item(is *issue, projectKey string) pluginapi.FetchedItem {
|
||||
fmt.Fprintf(&b, "\n_%d more comments are not included._\n", more)
|
||||
}
|
||||
}
|
||||
created, updated := f.Created.Time, f.Updated.Time
|
||||
return pluginapi.FetchedItem{
|
||||
ExternalID: is.ID,
|
||||
Title: is.Key + " " + f.Summary,
|
||||
Content: []byte(b.String()),
|
||||
ContentType: "text/markdown",
|
||||
FileName: fileName(is.Key + " " + f.Summary),
|
||||
URL: url,
|
||||
CreatedAt: nonZero(created),
|
||||
UpdatedAt: nonZero(updated),
|
||||
SourceResourceID: projectKey,
|
||||
Metadata: map[string]string{
|
||||
"channel": "jira", "jira_key": is.Key, "project": projectKey, "status": nameOf(f.Status),
|
||||
"issue_type": nameOf(f.IssueType), "assignee": userOf(f.Assignee), "priority": nameOf(f.Priority),
|
||||
"labels": strings.Join(f.Labels, ","),
|
||||
},
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func nameOf(n *named) string {
|
||||
@@ -393,13 +402,24 @@ func fileName(title string) string {
|
||||
return name + ".md"
|
||||
}
|
||||
|
||||
// siteOptions lists the Jira sites the connected account granted.
|
||||
func siteOptions(ctx context.Context, call *pluginsdk.Call, _ pluginapi.OptionsInput) ([]pluginapi.Option, error) {
|
||||
// formCredentials are the connection fields of the form being filled: a
|
||||
// data source's credentials, or the workspace's connection for the tools.
|
||||
func formCredentials(call *pluginsdk.Call, in pluginapi.OptionsInput) (credentials, error) {
|
||||
var cr credentials
|
||||
if in.Scope == pluginapi.OptionsScopeTenant {
|
||||
return cr, remarshal(call.Config.Tenant, &cr)
|
||||
}
|
||||
var cfg pluginsdk.ConnectorConfig
|
||||
if err := call.DecodeInstance(&cfg); err != nil {
|
||||
return nil, err
|
||||
return cr, err
|
||||
}
|
||||
cr, _, err := decode(cfg)
|
||||
return cr, err
|
||||
}
|
||||
|
||||
// siteOptions lists the Jira sites the connected account granted.
|
||||
func siteOptions(ctx context.Context, call *pluginsdk.Call, in pluginapi.OptionsInput) ([]pluginapi.Option, error) {
|
||||
cr, err := formCredentials(call, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -419,12 +439,12 @@ func siteOptions(ctx context.Context, call *pluginsdk.Call, _ pluginapi.OptionsI
|
||||
}
|
||||
|
||||
// issueTypeOptions lists the site's issue types by name (JQL matches names).
|
||||
func issueTypeOptions(ctx context.Context, call *pluginsdk.Call, _ pluginapi.OptionsInput) ([]pluginapi.Option, error) {
|
||||
var cfg pluginsdk.ConnectorConfig
|
||||
if err := call.DecodeInstance(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cr, _, err := decode(cfg)
|
||||
func issueTypeOptions(
|
||||
ctx context.Context,
|
||||
call *pluginsdk.Call,
|
||||
in pluginapi.OptionsInput,
|
||||
) ([]pluginapi.Option, error) {
|
||||
cr, err := formCredentials(call, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ var (
|
||||
msgTokenFields = message{"Enter the site URL, email and API token first.", "请先填写站点地址、邮箱和 API 令牌。"}
|
||||
msgUnknownAuth = message{"Unknown sign-in method.", "未知的登录方式。"}
|
||||
msgNoProjects = message{"Select at least one project to sync.", "请至少选择一个要同步的项目。"}
|
||||
msgToolsNotSetUp = message{
|
||||
"Jira is not connected for this workspace: an admin sets it up in Settings → Plugins → Jira.",
|
||||
"本空间尚未连接 Jira:请管理员在「设置 → 插件 → Jira」中配置。",
|
||||
}
|
||||
)
|
||||
|
||||
func say(locale string, m message) string {
|
||||
|
||||
@@ -4,8 +4,8 @@ version: 1.0.0
|
||||
apiVersion: weknora.plugin/v1
|
||||
name: { en-US: Jira, zh-CN: Jira }
|
||||
description:
|
||||
en-US: Sync Jira Cloud issues, with their comments, into a knowledge base, and a skill for triaging them. Connects with Atlassian OAuth or an API token; shows dynamic form options and OAuth fields.
|
||||
zh-CN: 把 Jira Cloud 的问题(含评论)同步到知识库,并提供问题分诊技能。支持 Atlassian OAuth 授权或 API 令牌;演示表单动态选项与 OAuth 字段。
|
||||
en-US: Sync Jira Cloud issues, with their comments, into a knowledge base; agent tools to search and read issues; and a skill for triaging them. Connects with Atlassian OAuth or an API token; shows dynamic form options and OAuth fields.
|
||||
zh-CN: 把 Jira Cloud 的问题(含评论)同步到知识库,提供搜索和读取问题的 Agent 工具,以及问题分诊技能。支持 Atlassian OAuth 授权或 API 令牌;演示表单动态选项与 OAuth 字段。
|
||||
publisher: { id: weknora-examples, name: WeKnora examples }
|
||||
homepage: https://github.com/Tencent/WeKnora/tree/main/examples/plugins/jira
|
||||
license: MIT
|
||||
@@ -23,6 +23,8 @@ config:
|
||||
# The platform's Atlassian OAuth 2.0 (3LO) app. Without it, workspaces can
|
||||
# still connect with an API token.
|
||||
system: schemas/system.yaml
|
||||
# The connection the agent tools use in this workspace.
|
||||
tenant: schemas/tenant.yaml
|
||||
contributes:
|
||||
connectors:
|
||||
- id: jira
|
||||
@@ -33,6 +35,24 @@ contributes:
|
||||
icon: icon.svg
|
||||
capabilities: [incremental, streaming]
|
||||
instanceSchema: schemas/connector.yaml
|
||||
mcpServers:
|
||||
# No url: the plugin serves these tools itself.
|
||||
- id: tools
|
||||
name: { en-US: Jira, zh-CN: Jira }
|
||||
description:
|
||||
en-US: Search Jira issues with JQL or text, and read one issue with its comments
|
||||
zh-CN: 用 JQL 或关键词搜索 Jira 问题,读取单个问题及其评论
|
||||
toolViews:
|
||||
search_issues:
|
||||
view: table
|
||||
items: issues
|
||||
columns:
|
||||
- { field: key, title: { en-US: Key, zh-CN: 编号 }, link: url }
|
||||
- { field: summary, title: { en-US: Summary, zh-CN: 摘要 } }
|
||||
- { field: status, title: { en-US: Status, zh-CN: 状态 } }
|
||||
- { field: assignee, title: { en-US: Assignee, zh-CN: 负责人 } }
|
||||
- { field: updated, title: { en-US: Updated, zh-CN: 更新时间 } }
|
||||
get_issue: { view: markdown, field: markdown }
|
||||
skills:
|
||||
- id: jira-triage
|
||||
name: { en-US: Jira triage, zh-CN: Jira 问题分诊 }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# The connection agent tools use in this workspace. Data sources have their
|
||||
# own, so each knowledge base can sync with a different account.
|
||||
type: object
|
||||
# Fields hidden by x-visible-if are not required.
|
||||
required: [auth, account, site, base_url, email, api_token]
|
||||
properties:
|
||||
auth:
|
||||
type: string
|
||||
title: Sign in with
|
||||
default: oauth
|
||||
x-order: 1
|
||||
oneOf:
|
||||
- const: oauth
|
||||
title: Atlassian account (OAuth)
|
||||
x-i18n: { title: { zh-CN: Atlassian 账号(OAuth) } }
|
||||
- const: token
|
||||
title: API token
|
||||
x-i18n: { title: { zh-CN: API 令牌 } }
|
||||
x-i18n:
|
||||
title: { zh-CN: 登录方式 }
|
||||
account:
|
||||
type: string
|
||||
title: Atlassian account
|
||||
description: Needs the platform's OAuth app (plugin management → Jira)
|
||||
x-order: 2
|
||||
x-visible-if: { auth: oauth }
|
||||
x-oauth:
|
||||
authorizeUrl: https://auth.atlassian.com/authorize
|
||||
tokenUrl: https://auth.atlassian.com/oauth/token
|
||||
scopes: [read:jira-work, read:jira-user, offline_access]
|
||||
clientId: ${system.client_id}
|
||||
clientSecret: ${system.client_secret}
|
||||
params: { audience: api.atlassian.com, prompt: consent }
|
||||
x-i18n:
|
||||
title: { zh-CN: Atlassian 账号 }
|
||||
description: { zh-CN: 需要平台先配置 OAuth 应用(插件管理 → Jira) }
|
||||
site:
|
||||
type: string
|
||||
title: Site
|
||||
description: The Jira site the account can reach
|
||||
x-order: 3
|
||||
x-visible-if: { auth: oauth }
|
||||
x-options: { name: sites, dependsOn: [account] }
|
||||
x-i18n:
|
||||
title: { zh-CN: 站点 }
|
||||
description: { zh-CN: 该账号可访问的 Jira 站点 }
|
||||
base_url:
|
||||
type: string
|
||||
title: Site URL
|
||||
x-order: 4
|
||||
x-visible-if: { auth: token }
|
||||
x-placeholder: https://your-team.atlassian.net
|
||||
pattern: "^https://[A-Za-z0-9-]+\\.atlassian\\.net/?$"
|
||||
x-i18n:
|
||||
title: { zh-CN: 站点地址 }
|
||||
email:
|
||||
type: string
|
||||
title: Email
|
||||
x-order: 5
|
||||
x-visible-if: { auth: token }
|
||||
format: email
|
||||
x-i18n:
|
||||
title: { zh-CN: 邮箱 }
|
||||
api_token:
|
||||
type: string
|
||||
title: API token
|
||||
description: Create one at id.atlassian.com → Security → API tokens
|
||||
x-order: 6
|
||||
x-secret: true
|
||||
x-visible-if: { auth: token }
|
||||
x-i18n:
|
||||
title: { zh-CN: API 令牌 }
|
||||
description: { zh-CN: 在 id.atlassian.com → 安全 → API 令牌 中创建 }
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/pluginsdk"
|
||||
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
||||
)
|
||||
|
||||
// toolsServer is the mcpServers contribution the plugin serves itself.
|
||||
const toolsServer = "tools"
|
||||
|
||||
func registerTools(p *pluginsdk.Plugin) {
|
||||
p.Tool(toolsServer, pluginapi.Tool{
|
||||
Name: "search_issues",
|
||||
Title: "Search Jira issues",
|
||||
Description: "Search Jira issues, newest update first. Give jql for precise queries " +
|
||||
`(e.g. project = ENG AND status != Done AND assignee = currentUser()), ` +
|
||||
"or text to match summaries, descriptions and comments.",
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{
|
||||
"jql":{"type":"string","description":"A JQL query; takes precedence over text and project"},
|
||||
"text":{"type":"string","description":"Words to search for"},
|
||||
"project":{"type":"string","description":"A project key to limit text search to"},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50,"default":20}}}`),
|
||||
Annotations: &pluginapi.ToolAnnotations{ReadOnlyHint: true},
|
||||
}, searchIssuesTool)
|
||||
p.Tool(toolsServer, pluginapi.Tool{
|
||||
Name: "get_issue",
|
||||
Title: "Read a Jira issue",
|
||||
Description: "Read one Jira issue by key (e.g. ENG-123): its facts, description and comments.",
|
||||
InputSchema: json.RawMessage(`{"type":"object","required":["key"],"properties":{
|
||||
"key":{"type":"string","description":"The issue key"}}}`),
|
||||
Annotations: &pluginapi.ToolAnnotations{ReadOnlyHint: true},
|
||||
}, getIssueTool)
|
||||
}
|
||||
|
||||
// toolClient connects with the workspace's connection (config.tenant).
|
||||
func toolClient(ctx context.Context, call *pluginsdk.Call) (*client, error) {
|
||||
var cr credentials
|
||||
if err := remarshal(call.Config.Tenant, &cr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := newClient(ctx, cr, call.Locale)
|
||||
if pe, ok := pluginapi.AsError(err); ok && pe.Code == pluginapi.CodeInvalidConfig {
|
||||
return nil, fmt.Errorf("%s %s", say(call.Locale, msgToolsNotSetUp), pe.Message)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// issueRow is one search result, as the result view shows it.
|
||||
type issueRow struct {
|
||||
Key string `json:"key"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Priority string `json:"priority"`
|
||||
Assignee string `json:"assignee"`
|
||||
Updated string `json:"updated"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func searchIssuesTool(ctx context.Context, call *pluginsdk.Call, args json.RawMessage) (*pluginapi.ToolResult, error) {
|
||||
var in struct {
|
||||
JQL string `json:"jql"`
|
||||
Text string `json:"text"`
|
||||
Project string `json:"project"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jql, err := toolJQL(in.JQL, in.Text, in.Project)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Limit <= 0 || in.Limit > 50 {
|
||||
in.Limit = 20
|
||||
}
|
||||
c, err := toolClient(ctx, call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issues, more, err := c.searchPage(ctx, jql, in.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]issueRow, len(issues))
|
||||
var text strings.Builder
|
||||
fmt.Fprintf(&text, "%d issue(s) for %s", len(issues), jql)
|
||||
if more {
|
||||
text.WriteString(" (more exist; narrow the query)")
|
||||
}
|
||||
text.WriteString("\n")
|
||||
for i := range issues {
|
||||
f := &issues[i].Fields
|
||||
rows[i] = issueRow{
|
||||
Key: issues[i].Key, Summary: f.Summary, Status: nameOf(f.Status), Type: nameOf(f.IssueType),
|
||||
Priority: nameOf(f.Priority), Assignee: userOf(f.Assignee), Updated: stamp(f.Updated),
|
||||
URL: c.issueURL(issues[i].Key),
|
||||
}
|
||||
r := rows[i]
|
||||
fmt.Fprintf(&text, "- %s [%s] %s (%s, %s) %s\n", r.Key, r.Status, r.Summary,
|
||||
firstNonEmpty(r.Assignee, "unassigned"), r.Updated, r.URL)
|
||||
}
|
||||
return pluginapi.StructuredResult(map[string]any{"jql": jql, "issues": rows, "more": more}, text.String()), nil
|
||||
}
|
||||
|
||||
var issueKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*-[0-9]+$`)
|
||||
|
||||
func getIssueTool(ctx context.Context, call *pluginsdk.Call, args json.RawMessage) (*pluginapi.ToolResult, error) {
|
||||
var in struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := strings.ToUpper(strings.TrimSpace(in.Key))
|
||||
if !issueKeyPattern.MatchString(key) {
|
||||
return pluginapi.ToolError("%q is not an issue key like ENG-123", in.Key), nil
|
||||
}
|
||||
c, err := toolClient(ctx, call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var is issue
|
||||
q := url.Values{"fields": {strings.Join(searchFields, ",")}}
|
||||
if err := c.do(ctx, http.MethodGet, "/rest/api/3/issue/"+url.PathEscape(key)+"?"+q.Encode(), nil, &is); err != nil {
|
||||
if pe, ok := pluginapi.AsError(err); ok && pe.Code == pluginapi.CodeNotFound {
|
||||
return pluginapi.ToolError("issue %s does not exist or is not visible to this account", key), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
md := c.markdown(&is)
|
||||
return pluginapi.StructuredResult(map[string]any{"key": is.Key, "url": c.issueURL(is.Key), "markdown": md}, md), nil
|
||||
}
|
||||
|
||||
// toolJQL is the query a search runs: the model's JQL as it is, or a text
|
||||
// search, newest update first.
|
||||
func toolJQL(jql, text, project string) (string, error) {
|
||||
if jql = strings.TrimSpace(jql); jql != "" {
|
||||
if !strings.Contains(strings.ToUpper(jql), "ORDER BY") {
|
||||
jql += " ORDER BY updated DESC"
|
||||
}
|
||||
return jql, nil
|
||||
}
|
||||
var parts []string
|
||||
if p := strings.TrimSpace(project); p != "" {
|
||||
parts = append(parts, "project = "+quote(p))
|
||||
}
|
||||
if t := strings.TrimSpace(text); t != "" {
|
||||
parts = append(parts, "text ~ "+quote(t))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("give jql, or text to search for")
|
||||
}
|
||||
return strings.Join(parts, " AND ") + " ORDER BY updated DESC", nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export interface MCPService {
|
||||
description: string
|
||||
usage_instructions?: string
|
||||
enabled: boolean
|
||||
transport_type: 'sse' | 'http-streamable' | 'stdio'
|
||||
transport_type: 'sse' | 'http-streamable' | 'stdio' | 'plugin'
|
||||
url?: string // Optional: required for SSE/HTTP Streamable
|
||||
headers?: Record<string, string>
|
||||
auth_config?: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import KnowledgeBaseList from '@/views/chat/components/tool-results/KnowledgeBas
|
||||
import KnowledgeChunksList from '@/views/chat/components/tool-results/KnowledgeChunksList.vue'
|
||||
import McpToolResult from '@/views/chat/components/tool-results/McpToolResult.vue'
|
||||
import PlanDisplay from '@/views/chat/components/tool-results/PlanDisplay.vue'
|
||||
import PluginToolView from '@/views/chat/components/tool-results/PluginToolView.vue'
|
||||
import ReadSkillResult from '@/views/chat/components/tool-results/ReadSkillResult.vue'
|
||||
import RelatedChunks from '@/views/chat/components/tool-results/RelatedChunks.vue'
|
||||
import SandboxFilesResult from '@/views/chat/components/tool-results/SandboxFilesResult.vue'
|
||||
@@ -59,6 +60,11 @@ const VIEWS: Record<string, View> = {
|
||||
component: McpToolResult,
|
||||
props: (c) => ({ discovery: true, data: c.data, output: c.output, arguments: c.arguments, success: c.success }),
|
||||
},
|
||||
// Results of plugin tools, shown with the view the plugin declared.
|
||||
plugin_tool_view: {
|
||||
component: PluginToolView,
|
||||
props: (c) => ({ data: c.data, output: c.output, arguments: c.arguments, success: c.success }),
|
||||
},
|
||||
mcp_call: {
|
||||
component: McpToolResult,
|
||||
props: (c) => ({ discovery: false, data: c.data, output: c.output, arguments: c.arguments, success: c.success }),
|
||||
|
||||
@@ -29,7 +29,7 @@ import { getApiBaseUrl } from '@/utils/api-base'
|
||||
import { localizedText } from '@/utils/localizedText'
|
||||
|
||||
import { BridgeCallError, createBridgeHost, readTheme, type BridgeInit } from './bridgeHost'
|
||||
import { pageFileUrl, type PluginPage } from './pluginPages'
|
||||
import { pageFileUrl, type FramePage } from './pluginPages'
|
||||
|
||||
// One plugin page in a sandboxed iframe: an opaque origin (no
|
||||
// allow-same-origin), so it cannot read WeKnora's storage or call its API.
|
||||
@@ -38,7 +38,7 @@ import { pageFileUrl, type PluginPage } from './pluginPages'
|
||||
// new window has its own origin and no way back into the app.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: PluginPage
|
||||
page: FramePage
|
||||
/** What the mount tells the page, e.g. { knowledgeBaseId }. */
|
||||
context?: Record<string, unknown>
|
||||
/** Fill the container instead of growing with the page's content. */
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface PluginPage {
|
||||
order: number
|
||||
}
|
||||
|
||||
/** What a frame needs of a page: tool result pages are not listed pages. */
|
||||
export type FramePage = Pick<PluginPage, 'pluginId' | 'version' | 'mount' | 'entry' | 'name'>
|
||||
|
||||
/** The role a page needs when the plugin names none. */
|
||||
export function defaultMinRole(point: PagePoint): PageRole {
|
||||
return point === 'settingsSections' ? 'admin' : 'viewer'
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
cardsModel,
|
||||
display,
|
||||
kvModel,
|
||||
markdownSource,
|
||||
safeHref,
|
||||
tableModel,
|
||||
toolArguments,
|
||||
valueAt,
|
||||
type ToolViewSpec,
|
||||
} from './pluginToolView'
|
||||
|
||||
const result = {
|
||||
total: 2,
|
||||
issues: [
|
||||
{ key: 'ENG-1', summary: 'Login fails', url: 'https://acme.atlassian.net/browse/ENG-1', fields: { status: 'Open' }, labels: ['sso', 'web'] },
|
||||
{ key: 'ENG-2', summary: 'Evil', url: 'javascript:alert(1)', fields: { status: 'Done' } },
|
||||
],
|
||||
}
|
||||
|
||||
test('tables take their rows from the items path and link only to http(s)', () => {
|
||||
const spec: ToolViewSpec = {
|
||||
view: 'table',
|
||||
items: 'issues',
|
||||
columns: [
|
||||
{ field: 'key', link: 'url' },
|
||||
{ field: 'summary', title: { default: 'Summary', 'zh-CN': '摘要' } },
|
||||
{ field: 'fields.status' },
|
||||
{ field: 'labels' },
|
||||
],
|
||||
}
|
||||
const t = tableModel(spec, result, 'zh-CN')
|
||||
assert.deepEqual(t.headers, ['key', '摘要', 'fields.status', 'labels'])
|
||||
assert.deepEqual(t.rows[0], [
|
||||
{ text: 'ENG-1', href: 'https://acme.atlassian.net/browse/ENG-1' },
|
||||
{ text: 'Login fails', href: undefined },
|
||||
{ text: 'Open', href: undefined },
|
||||
{ text: 'sso, web', href: undefined },
|
||||
])
|
||||
assert.equal(t.rows[1][0].href, undefined)
|
||||
assert.deepEqual(tableModel({ ...spec, items: 'missing' }, result, 'en-US').rows, [])
|
||||
})
|
||||
|
||||
test('cards, kv and markdown views read their fields', () => {
|
||||
const cards = cardsModel({ view: 'cards', items: 'issues', title: 'key', subtitle: 'fields.status', link: 'url' }, result)
|
||||
assert.deepEqual(cards[0], {
|
||||
title: 'ENG-1', subtitle: 'Open', body: '', href: 'https://acme.atlassian.net/browse/ENG-1',
|
||||
})
|
||||
const kv = kvModel({ view: 'kv', items: 'issues.0', title: 'key', link: 'url' }, { issues: { 0: result.issues[0] } }, 'en-US')
|
||||
assert.equal(kv.title, 'ENG-1')
|
||||
assert.deepEqual(kv.rows.map((r) => r.label), ['summary', 'fields', 'labels'])
|
||||
assert.equal(kv.rows[1].text, '{"status":"Open"}')
|
||||
const picked = kvModel({ view: 'kv', fields: [{ field: 'total', title: { default: 'Total' } }] }, result, 'en-US')
|
||||
assert.deepEqual(picked.rows, [{ label: 'Total', text: '2', href: undefined }])
|
||||
assert.equal(markdownSource({ view: 'markdown', field: 'md' }, { md: '# Hi' }, 'text'), '# Hi')
|
||||
assert.equal(markdownSource({ view: 'markdown' }, {}, 'text'), 'text')
|
||||
})
|
||||
|
||||
test('helpers', () => {
|
||||
assert.equal(valueAt({ a: { b: 1 } }, 'a.b'), 1)
|
||||
assert.equal(valueAt({ a: [1] }, 'a.0'), undefined)
|
||||
assert.equal(display(null), '')
|
||||
assert.equal(display(false), 'false')
|
||||
assert.equal(safeHref('ftp://x'), undefined)
|
||||
assert.deepEqual(toolArguments({ service: 's', tool: 't', arguments: { q: 1 } }), { q: 1 })
|
||||
assert.deepEqual(toolArguments({ q: 1 }), { q: 1 })
|
||||
assert.deepEqual(toolArguments(undefined), {})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
// Plugin tool result views (toolViews in plugin.yaml): a plugin maps its
|
||||
// tool's structured result onto one of these generic views, so the chat
|
||||
// can show it without running plugin code. This module is the pure part of
|
||||
// PluginToolView.vue.
|
||||
import { localizedText, type LocalizedText } from '../utils/localizedText'
|
||||
|
||||
export type ToolViewKind = 'table' | 'cards' | 'kv' | 'markdown' | 'json' | 'page'
|
||||
|
||||
export interface ToolViewColumn {
|
||||
field: string
|
||||
title?: LocalizedText
|
||||
link?: string
|
||||
}
|
||||
|
||||
/** Same shape as manifest.ToolView on the backend. */
|
||||
export interface ToolViewSpec {
|
||||
view: ToolViewKind
|
||||
items?: string
|
||||
columns?: ToolViewColumn[]
|
||||
title?: string
|
||||
subtitle?: string
|
||||
body?: string
|
||||
link?: string
|
||||
fields?: ToolViewColumn[]
|
||||
field?: string
|
||||
entry?: string
|
||||
}
|
||||
|
||||
export interface Cell {
|
||||
text: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
/** Field at a dotted path ("fields.status"); undefined when absent. */
|
||||
export function valueAt(value: unknown, path: string | undefined): unknown {
|
||||
if (!path) return value
|
||||
let v: unknown = value
|
||||
for (const key of path.split('.')) {
|
||||
if (typeof v !== 'object' || v === null || Array.isArray(v)) return undefined
|
||||
v = (v as Record<string, unknown>)[key]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/** A value as a cell's text: scalars as they are, lists joined, objects as JSON. */
|
||||
export function display(value: unknown): string {
|
||||
if (value === undefined || value === null) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (Array.isArray(value) && value.every((v) => typeof v !== 'object' || v === null)) {
|
||||
return value.map(display).join(', ')
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Links only to http(s): plugin data must not inject javascript: URLs. */
|
||||
export function safeHref(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
try {
|
||||
const u = new URL(value)
|
||||
return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function list(structured: unknown, path: string | undefined): unknown[] {
|
||||
const v = valueAt(structured, path)
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
export interface TableModel {
|
||||
headers: string[]
|
||||
rows: Cell[][]
|
||||
}
|
||||
|
||||
export function tableModel(spec: ToolViewSpec, structured: unknown, locale: string): TableModel {
|
||||
const columns = spec.columns ?? []
|
||||
return {
|
||||
headers: columns.map((c) => localizedText(c.title, locale) || c.field),
|
||||
rows: list(structured, spec.items).map((item) =>
|
||||
columns.map((c) => ({ text: display(valueAt(item, c.field)), href: safeHref(valueAt(item, c.link)) })),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export interface CardModel {
|
||||
title: string
|
||||
subtitle: string
|
||||
body: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function cardsModel(spec: ToolViewSpec, structured: unknown): CardModel[] {
|
||||
return list(structured, spec.items).map((item) => ({
|
||||
title: display(valueAt(item, spec.title)),
|
||||
subtitle: spec.subtitle ? display(valueAt(item, spec.subtitle)) : '',
|
||||
body: spec.body ? display(valueAt(item, spec.body)) : '',
|
||||
href: safeHref(valueAt(item, spec.link)),
|
||||
}))
|
||||
}
|
||||
|
||||
export interface KVModel {
|
||||
title: string
|
||||
href?: string
|
||||
rows: Array<{ label: string } & Cell>
|
||||
}
|
||||
|
||||
export function kvModel(spec: ToolViewSpec, structured: unknown, locale: string): KVModel {
|
||||
const obj = valueAt(structured, spec.items)
|
||||
const record = typeof obj === 'object' && obj !== null && !Array.isArray(obj) ? (obj as Record<string, unknown>) : {}
|
||||
const fields: ToolViewColumn[] = spec.fields?.length
|
||||
? spec.fields
|
||||
: Object.keys(record)
|
||||
.filter((k) => k !== spec.title && k !== spec.link)
|
||||
.map((field) => ({ field }))
|
||||
return {
|
||||
title: spec.title ? display(valueAt(record, spec.title)) : '',
|
||||
href: safeHref(valueAt(record, spec.link)),
|
||||
rows: fields
|
||||
.map((f) => ({
|
||||
label: localizedText(f.title, locale) || f.field,
|
||||
text: display(valueAt(record, f.field)),
|
||||
href: safeHref(valueAt(record, f.link)),
|
||||
}))
|
||||
.filter((r) => r.text !== ''),
|
||||
}
|
||||
}
|
||||
|
||||
/** The Markdown a markdown view shows: its field, else the text for the model. */
|
||||
export function markdownSource(spec: ToolViewSpec, structured: unknown, output: string): string {
|
||||
const v = spec.field ? valueAt(structured, spec.field) : undefined
|
||||
return typeof v === 'string' ? v : output
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments the tool was called with. Through the call_mcp_tool proxy
|
||||
* the real arguments sit under "arguments".
|
||||
*/
|
||||
export function toolArguments(args: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
const inner = args?.arguments
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner as Record<string, unknown>
|
||||
return args ?? {}
|
||||
}
|
||||
|
||||
export interface PluginToolViewData {
|
||||
plugin_view?: ToolViewSpec
|
||||
plugin_id?: string
|
||||
plugin_version?: string
|
||||
mcp_server?: string
|
||||
mcp_tool?: string
|
||||
structured?: unknown
|
||||
}
|
||||
@@ -2859,6 +2859,9 @@ export default {
|
||||
rotateFailed: 'Failed to rotate the secret'
|
||||
}
|
||||
},
|
||||
pluginToolView: {
|
||||
empty: 'No results'
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: 'The plugin page is not responding; it may have failed to load.',
|
||||
requestFailed: 'The plugin request failed',
|
||||
@@ -5555,6 +5558,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
transportPlugin: 'Built into plugin',
|
||||
fromPlugin: 'Plugin',
|
||||
pluginNotConfigured: 'The plugin is not configured yet; fill in its settings in Plugins to use it',
|
||||
addUsageInstructions: "Add usage instructions",
|
||||
|
||||
@@ -2859,6 +2859,9 @@ export default {
|
||||
rotateFailed: 'シークレットのローテーションに失敗しました'
|
||||
}
|
||||
},
|
||||
pluginToolView: {
|
||||
empty: '結果がありません'
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: 'プラグインページが応答しません。読み込みに失敗した可能性があります。',
|
||||
requestFailed: 'プラグインへのリクエストに失敗しました',
|
||||
@@ -5555,6 +5558,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
transportPlugin: 'プラグイン内蔵',
|
||||
fromPlugin: 'プラグイン',
|
||||
pluginNotConfigured: 'プラグインが未設定です。プラグインセンターで設定してから使用してください',
|
||||
addUsageInstructions: "使用方法を追加",
|
||||
|
||||
@@ -2663,6 +2663,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
transportPlugin: '플러그인 내장',
|
||||
fromPlugin: '플러그인',
|
||||
pluginNotConfigured: '플러그인이 아직 설정되지 않았습니다. 플러그인 센터에서 설정한 뒤 사용하세요',
|
||||
addUsageInstructions: "사용 안내 추가",
|
||||
@@ -5377,6 +5378,9 @@ export default {
|
||||
rotateFailed: '비밀 키를 교체하지 못했습니다'
|
||||
}
|
||||
},
|
||||
pluginToolView: {
|
||||
empty: '결과가 없습니다'
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: '플러그인 페이지가 응답하지 않습니다. 로드에 실패했을 수 있습니다.',
|
||||
requestFailed: '플러그인 요청에 실패했습니다',
|
||||
|
||||
@@ -2663,6 +2663,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
transportPlugin: 'Встроено в плагин',
|
||||
fromPlugin: 'Плагин',
|
||||
pluginNotConfigured: 'Плагин ещё не настроен; заполните его настройки в разделе «Плагины»',
|
||||
addUsageInstructions: "Добавить инструкции",
|
||||
@@ -5377,6 +5378,9 @@ export default {
|
||||
rotateFailed: 'Не удалось сменить секрет'
|
||||
}
|
||||
},
|
||||
pluginToolView: {
|
||||
empty: 'Нет результатов'
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: 'Страница плагина не отвечает; возможно, она не загрузилась.',
|
||||
requestFailed: 'Запрос к плагину не выполнен',
|
||||
|
||||
@@ -2665,6 +2665,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mcpSettings: {
|
||||
transportPlugin: '插件内置',
|
||||
fromPlugin: '插件',
|
||||
pluginNotConfigured: '插件尚未配置,请在插件中心填写配置后使用',
|
||||
addUsageInstructions: "添加使用说明",
|
||||
@@ -5379,6 +5380,9 @@ export default {
|
||||
rotateFailed: '轮换密钥失败'
|
||||
}
|
||||
},
|
||||
pluginToolView: {
|
||||
empty: '没有结果'
|
||||
},
|
||||
pluginPages: {
|
||||
notResponding: '插件页面没有响应,可能加载失败。',
|
||||
requestFailed: '插件请求失败',
|
||||
|
||||
@@ -32,7 +32,8 @@ export type DisplayType =
|
||||
| 'edit_sandbox_file'
|
||||
| 'read_skill'
|
||||
| 'mcp_discovery'
|
||||
| 'mcp_call';
|
||||
| 'mcp_call'
|
||||
| 'plugin_tool_view';
|
||||
|
||||
// Search result item
|
||||
export interface SearchResultItem {
|
||||
|
||||
@@ -1221,6 +1221,9 @@ const formatToolResultContent = (value: unknown): string => {
|
||||
const isMcpTool = (toolName?: string | null): boolean => String(toolName || '').startsWith('mcp_');
|
||||
|
||||
const resolveToolDisplayType = (event: any): DisplayType | undefined => {
|
||||
// A plugin's own result view beats the generic MCP call view, also when
|
||||
// the tool was reached through call_mcp_tool.
|
||||
if (isPluginToolView(event)) return 'plugin_tool_view'
|
||||
const mcpType = getMcpToolDisplayType(event?.tool_name)
|
||||
if (mcpType) return mcpType
|
||||
if (event?.display_type) return event.display_type as DisplayType
|
||||
@@ -1320,8 +1323,13 @@ const buildToolResultReference = (
|
||||
}];
|
||||
};
|
||||
|
||||
// A plugin tool whose result has a view expands in place to show it,
|
||||
// instead of opening the text-only references drawer MCP tools use.
|
||||
const isPluginToolView = (event: any): boolean =>
|
||||
event?.display_type === 'plugin_tool_view' || event?.tool_data?.display_type === 'plugin_tool_view';
|
||||
|
||||
function getToolReferenceItems(event: any): KnowledgeReferenceLike[] {
|
||||
if (!event || event.pending) return [];
|
||||
if (!event || event.pending || isPluginToolView(event)) return [];
|
||||
const toolName = event.tool_name;
|
||||
const toolData = event.tool_data;
|
||||
|
||||
@@ -2239,6 +2247,7 @@ const isReferenceDrawerTool = (toolName?: string | null): boolean =>
|
||||
toolName === 'wiki_read_source_doc';
|
||||
|
||||
const hasExpandableResults = (event: any): boolean => {
|
||||
if (isPluginToolView(event)) return !event.pending;
|
||||
if (isReferenceDrawerTool(event?.tool_name)) return false;
|
||||
return hasResults(event);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="plugin-tool-view">
|
||||
<div v-if="success === false" class="plugin-tool-view__error" role="alert">
|
||||
<t-icon name="error-circle" />
|
||||
<pre>{{ output }}</pre>
|
||||
</div>
|
||||
|
||||
<template v-else-if="spec.view === 'table'">
|
||||
<p v-if="!table.rows.length" class="plugin-tool-view__empty">{{ t('pluginToolView.empty') }}</p>
|
||||
<div v-else class="plugin-tool-view__table-wrap">
|
||||
<table class="plugin-tool-view__table">
|
||||
<thead>
|
||||
<tr><th v-for="(h, i) in table.headers" :key="i">{{ h }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, r) in table.rows" :key="r">
|
||||
<td v-for="(cell, c) in row" :key="c">
|
||||
<a v-if="cell.href" :href="cell.href" target="_blank" rel="noopener noreferrer">{{ cell.text }}</a>
|
||||
<template v-else>{{ cell.text }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="spec.view === 'cards'">
|
||||
<p v-if="!cards.length" class="plugin-tool-view__empty">{{ t('pluginToolView.empty') }}</p>
|
||||
<div v-else class="plugin-tool-view__cards">
|
||||
<article v-for="(card, i) in cards" :key="i" class="plugin-tool-view__card">
|
||||
<a v-if="card.href" class="plugin-tool-view__card-title" :href="card.href" target="_blank" rel="noopener noreferrer">{{ card.title }}</a>
|
||||
<div v-else class="plugin-tool-view__card-title">{{ card.title }}</div>
|
||||
<div v-if="card.subtitle" class="plugin-tool-view__card-subtitle">{{ card.subtitle }}</div>
|
||||
<p v-if="card.body" class="plugin-tool-view__card-body">{{ card.body }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="spec.view === 'kv'">
|
||||
<div v-if="kv.title" class="plugin-tool-view__kv-title">
|
||||
<a v-if="kv.href" :href="kv.href" target="_blank" rel="noopener noreferrer">{{ kv.title }}</a>
|
||||
<template v-else>{{ kv.title }}</template>
|
||||
</div>
|
||||
<dl class="plugin-tool-view__kv">
|
||||
<template v-for="(row, i) in kv.rows" :key="i">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>
|
||||
<a v-if="row.href" :href="row.href" target="_blank" rel="noopener noreferrer">{{ row.text }}</a>
|
||||
<template v-else>{{ row.text }}</template>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by renderDocumentPreviewMarkdown -->
|
||||
<div v-else-if="spec.view === 'markdown'" class="plugin-tool-view__markdown" v-html="markdownHtml" />
|
||||
|
||||
<PluginFrame v-else-if="spec.view === 'page' && page" :page="page" :context="pageContext" />
|
||||
|
||||
<pre v-else class="plugin-tool-view__json">{{ json }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PluginFrame from '@/extensions/pluginFrame/PluginFrame.vue'
|
||||
import type { FramePage } from '@/extensions/pluginFrame/pluginPages'
|
||||
import {
|
||||
cardsModel,
|
||||
kvModel,
|
||||
markdownSource,
|
||||
tableModel,
|
||||
toolArguments,
|
||||
type PluginToolViewData,
|
||||
type ToolViewSpec,
|
||||
} from '@/extensions/pluginToolView'
|
||||
import { renderDocumentPreviewMarkdown } from '@/utils/documentPreviewMarkdown'
|
||||
|
||||
// A plugin tool's structured result, shown with the view its plugin
|
||||
// declared in toolViews: generic views need no plugin code; a page view runs
|
||||
// the plugin's own page in a sandboxed frame.
|
||||
const props = defineProps<{
|
||||
data: PluginToolViewData & Record<string, unknown>
|
||||
output?: string
|
||||
arguments?: Record<string, unknown>
|
||||
success?: boolean
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const spec = computed<ToolViewSpec>(() => props.data.plugin_view ?? { view: 'json' })
|
||||
const structured = computed(() => props.data.structured)
|
||||
const output = computed(() => props.output || '')
|
||||
|
||||
const table = computed(() => tableModel(spec.value, structured.value, locale.value))
|
||||
const cards = computed(() => cardsModel(spec.value, structured.value))
|
||||
const kv = computed(() => kvModel(spec.value, structured.value, locale.value))
|
||||
const markdownHtml = computed(() =>
|
||||
renderDocumentPreviewMarkdown(markdownSource(spec.value, structured.value, output.value)),
|
||||
)
|
||||
const json = computed(() => {
|
||||
try {
|
||||
return JSON.stringify(structured.value, null, 2)
|
||||
} catch {
|
||||
return String(structured.value)
|
||||
}
|
||||
})
|
||||
|
||||
const page = computed<FramePage | null>(() => {
|
||||
const d = props.data
|
||||
if (!d.plugin_id || !d.plugin_version || !d.mcp_server || !spec.value.entry) return null
|
||||
return {
|
||||
pluginId: d.plugin_id,
|
||||
version: d.plugin_version,
|
||||
// Requests from the page answer to the tool's server.
|
||||
mount: `mcpServers/${d.mcp_server}`,
|
||||
entry: spec.value.entry,
|
||||
name: { default: d.mcp_tool || d.plugin_id },
|
||||
}
|
||||
})
|
||||
const pageContext = computed(() => ({
|
||||
tool: props.data.mcp_tool,
|
||||
arguments: toolArguments(props.arguments),
|
||||
result: structured.value,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.plugin-tool-view {
|
||||
margin: 8px 0;
|
||||
font-size: var(--app-text-md);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.plugin-tool-view__empty {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.plugin-tool-view__error {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
color: var(--td-error-color);
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-tool-view__table-wrap {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
|
||||
.plugin-tool-view__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid var(--td-component-stroke);
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-secondary);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-tool-view__cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-tool-view__card {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
|
||||
.plugin-tool-view__card-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plugin-tool-view__card-subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: var(--app-text-sm);
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.plugin-tool-view__card-body {
|
||||
margin: 4px 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.plugin-tool-view__kv-title {
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plugin-tool-view__kv {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 16px;
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-tool-view__json {
|
||||
max-height: 360px;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
overflow: auto;
|
||||
font-size: var(--app-text-sm);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--td-brand-color);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -241,6 +241,9 @@ const getTransportTypeLabel = (transportType: string) => {
|
||||
return 'HTTP Streamable'
|
||||
case 'stdio':
|
||||
return 'Stdio'
|
||||
case 'plugin':
|
||||
// Served by the plugin itself: there is no URL to show.
|
||||
return t('mcpSettings.transportPlugin')
|
||||
default:
|
||||
return transportType
|
||||
}
|
||||
|
||||
@@ -328,6 +328,7 @@ func (t *MCPTool) Execute(ctx context.Context, args json.RawMessage) (*types.Too
|
||||
// double storage in memory and accidental exposure in logs/SSE.
|
||||
data := make(map[string]interface{})
|
||||
data["content_items"] = redactImageData(result.Content)
|
||||
addPluginToolView(data, t.service, t.mcpTool.Name, result.StructuredContent)
|
||||
|
||||
logger.GetLogger(ctx).Infof("MCP tool executed successfully: %s (images: %d)", t.mcpTool.Name, len(images))
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// PluginToolViewDisplayType marks a tool result the chat renders with the
|
||||
// result view its plugin declared (toolViews in plugin.yaml).
|
||||
const PluginToolViewDisplayType = "plugin_tool_view"
|
||||
|
||||
// maxPluginViewBytes caps the structured result kept for a view; larger
|
||||
// results show as plain output.
|
||||
const maxPluginViewBytes = 256 << 10
|
||||
|
||||
// addPluginToolView attaches a plugin's result view and the structured
|
||||
// result it shows. The model still reads the text content.
|
||||
func addPluginToolView(data map[string]any, svc *types.MCPService, tool string, structured any) {
|
||||
if svc == nil || structured == nil {
|
||||
return
|
||||
}
|
||||
view, ok := svc.ToolViews[tool]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(structured)
|
||||
if err != nil || len(raw) > maxPluginViewBytes {
|
||||
return
|
||||
}
|
||||
data["display_type"] = PluginToolViewDisplayType
|
||||
data["plugin_view"] = view
|
||||
data["plugin_id"] = svc.PluginID
|
||||
data["plugin_version"] = svc.PluginVersion
|
||||
data["mcp_server"] = svc.PluginServer
|
||||
data["mcp_tool"] = tool
|
||||
data["structured"] = json.RawMessage(raw)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestPluginToolViewTagsStructuredResults(t *testing.T) {
|
||||
svc := &types.MCPService{
|
||||
PluginID: "acme.jira", PluginVersion: "1.0.0",
|
||||
ToolViews: map[string]json.RawMessage{"search": json.RawMessage(`{"view":"table"}`)},
|
||||
}
|
||||
data := map[string]any{}
|
||||
addPluginToolView(data, svc, "search", map[string]any{"issues": []any{}})
|
||||
if data["display_type"] != PluginToolViewDisplayType || data["plugin_id"] != "acme.jira" ||
|
||||
data["mcp_tool"] != "search" || string(data["structured"].(json.RawMessage)) != `{"issues":[]}` {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
if !ShouldOmitRawToolOutput("", data) {
|
||||
t.Fatal("a rendered view replaces the raw output in the transcript")
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
tool string
|
||||
structured any
|
||||
}{
|
||||
"no view for the tool": {"other", map[string]any{}},
|
||||
"no structured content": {"search", nil},
|
||||
"too large": {"search", strings.Repeat("x", maxPluginViewBytes)},
|
||||
} {
|
||||
data := map[string]any{}
|
||||
addPluginToolView(data, svc, tc.tool, tc.structured)
|
||||
if len(data) != 0 {
|
||||
t.Errorf("%s: %v", name, data)
|
||||
}
|
||||
}
|
||||
addPluginToolView(data, nil, "search", map[string]any{})
|
||||
}
|
||||
@@ -102,6 +102,7 @@ func bindPluginActivators(
|
||||
skills *service.TenantSkillService,
|
||||
) {
|
||||
a.MCP.Bind(t, repo)
|
||||
a.MCP.SetInvoker(a.Invoker)
|
||||
a.Invoker.Bind(t, repo)
|
||||
a.Skills.Bind(t)
|
||||
skills.SetPluginSkills(a.Skills)
|
||||
|
||||
+14
-5
@@ -231,7 +231,15 @@ func NewMCPClient(config *ClientConfig) (MCPClient, error) {
|
||||
// Stdio transport is disabled for security reasons (potential command injection vulnerabilities)
|
||||
return nil, fmt.Errorf("stdio transport is disabled for security reasons; please use SSE or HTTP Streamable transport instead")
|
||||
default:
|
||||
return nil, ErrUnsupportedTransport
|
||||
factory, ok := registeredTransport(config.Service.TransportType)
|
||||
if !ok {
|
||||
return nil, ErrUnsupportedTransport
|
||||
}
|
||||
t, err := factory(config.Service)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcpClient = client.NewClient(t)
|
||||
}
|
||||
|
||||
instance := &mcpGoClient{
|
||||
@@ -291,7 +299,7 @@ func buildOAuthConfig(config *ClientConfig, httpClient *http.Client) (transport.
|
||||
// onConnectionLost callback when the connection is lost
|
||||
func (c *mcpGoClient) onConnectionLost(err error) {
|
||||
_ = c.Disconnect()
|
||||
logger.Warnf(context.Background(), "MCP server connection has been lost, URL:%s, error:%v", *c.service.URL, err)
|
||||
logger.Warnf(context.Background(), "MCP server connection has been lost, %s, error:%v", target(c.service), err)
|
||||
}
|
||||
|
||||
// checkErrorAndDisconnectIfNeeded checks for transport errors that indicate the
|
||||
@@ -355,7 +363,7 @@ func (c *mcpGoClient) Connect(ctx context.Context) error {
|
||||
logger.GetLogger(ctx).Infof("MCP stdio client connected: %s %v",
|
||||
c.service.StdioConfig.Command, c.service.StdioConfig.Args)
|
||||
} else {
|
||||
logger.GetLogger(ctx).Infof("MCP client connected to %s", *c.service.URL)
|
||||
logger.GetLogger(ctx).Infof("MCP client connected to %s", target(c.service))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -617,8 +625,9 @@ func (c *mcpGoClient) CallTool(ctx context.Context, name string, args map[string
|
||||
}
|
||||
|
||||
return &CallToolResult{
|
||||
IsError: result.IsError,
|
||||
Content: content,
|
||||
IsError: result.IsError,
|
||||
Content: content,
|
||||
StructuredContent: result.StructuredContent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// TransportFactory builds the transport of a service whose transport type
|
||||
// another package provides, such as MCP servers plugins serve themselves.
|
||||
type TransportFactory func(svc *types.MCPService) (transport.Interface, error)
|
||||
|
||||
var (
|
||||
transportsMu sync.RWMutex
|
||||
transports = map[types.MCPTransportType]TransportFactory{}
|
||||
)
|
||||
|
||||
// RegisterTransport makes NewMCPClient build services of a transport type
|
||||
// with f. Registering again replaces the factory.
|
||||
func RegisterTransport(kind types.MCPTransportType, f TransportFactory) {
|
||||
transportsMu.Lock()
|
||||
defer transportsMu.Unlock()
|
||||
transports[kind] = f
|
||||
}
|
||||
|
||||
func registeredTransport(kind types.MCPTransportType) (TransportFactory, bool) {
|
||||
transportsMu.RLock()
|
||||
defer transportsMu.RUnlock()
|
||||
f, ok := transports[kind]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// target names what a client talks to, for logs.
|
||||
func target(svc *types.MCPService) string {
|
||||
if svc.URL != nil && *svc.URL != "" {
|
||||
return "URL:" + *svc.URL
|
||||
}
|
||||
if svc.PluginID != "" {
|
||||
return "plugin:" + svc.PluginID + "/" + svc.PluginServer
|
||||
}
|
||||
return "transport:" + string(svc.TransportType)
|
||||
}
|
||||
@@ -45,6 +45,8 @@ type ServerInfo struct {
|
||||
type CallToolResult struct {
|
||||
Content []ContentItem `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
// StructuredContent is the tool's structured result, if it returns one.
|
||||
StructuredContent any `json:"structuredContent,omitempty"`
|
||||
}
|
||||
|
||||
// ContentItem represents a content item in tool result
|
||||
|
||||
@@ -4,6 +4,7 @@ package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -43,6 +44,7 @@ type MCPServers struct {
|
||||
tenancy *tenancy.Service
|
||||
plugins interfaces.PluginRepository
|
||||
servers map[string][]mcpServer // plugin ID → servers
|
||||
iv *Invoker
|
||||
// directories are the tool directory snapshots of plugin services, by
|
||||
// plugin ID. They cannot be stored (mcp_metadata references stored
|
||||
// services) and are cheap to list again, so each node keeps its own and
|
||||
@@ -73,9 +75,6 @@ func (a *MCPServers) Activate(_ context.Context, l *reconcile.Loaded) error {
|
||||
var list []mcpServer
|
||||
now := time.Now()
|
||||
for _, c := range l.Manifest.Contributes[manifest.PointMCPServers] {
|
||||
if c.MCP == nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, mcpServer{
|
||||
manifest: l.Manifest, contrib: c, qualifiedID: l.Manifest.ID + "/" + c.ID, activatedAt: now,
|
||||
})
|
||||
@@ -141,24 +140,32 @@ func (a *MCPServers) Services(ctx context.Context, tenantID uint64) []*types.MCP
|
||||
// headers cannot be filled (the workspace has not configured the plugin yet)
|
||||
// is listed disabled, so agents skip it and the UI can say why.
|
||||
func (a *MCPServers) service(ctx context.Context, tenantID uint64, s mcpServer) *types.MCPService {
|
||||
url := s.contrib.MCP.URL
|
||||
transport := types.MCPTransportType(s.contrib.MCP.Transport)
|
||||
if transport == "" {
|
||||
transport = types.MCPTransportHTTPStreamable
|
||||
}
|
||||
svc := &types.MCPService{
|
||||
ID: serviceID(tenantID, s.qualifiedID),
|
||||
TenantID: tenantID,
|
||||
Name: s.contrib.Name.Default,
|
||||
Description: s.contrib.Description.Default,
|
||||
Enabled: true,
|
||||
TransportType: transport,
|
||||
URL: &url,
|
||||
IsBuiltin: true,
|
||||
PluginID: s.manifest.ID,
|
||||
PluginVersion: s.manifest.Version,
|
||||
PluginServer: s.contrib.ID,
|
||||
ToolViews: toolViews(s.contrib),
|
||||
CreatedAt: s.activatedAt,
|
||||
UpdatedAt: s.activatedAt,
|
||||
}
|
||||
if s.contrib.ServedByPlugin() {
|
||||
// Configuration travels with each call, so the connection never
|
||||
// goes stale when it changes.
|
||||
svc.TransportType = types.MCPTransportPlugin
|
||||
return svc
|
||||
}
|
||||
url := s.contrib.MCP.URL
|
||||
svc.URL = &url
|
||||
svc.TransportType = types.MCPTransportType(s.contrib.MCP.Transport)
|
||||
if svc.TransportType == "" {
|
||||
svc.TransportType = types.MCPTransportHTTPStreamable
|
||||
}
|
||||
headers, updated, err := a.headers(ctx, tenantID, s)
|
||||
if err != nil {
|
||||
logger.Debugf(ctx, "[plugin] MCP server %s in tenant %d: %v", s.qualifiedID, tenantID, err)
|
||||
@@ -219,6 +226,20 @@ func (a *MCPServers) headers(ctx context.Context, tenantID uint64, s mcpServer)
|
||||
return out, updated, nil
|
||||
}
|
||||
|
||||
// toolViews are a contribution's result views as the chat reads them.
|
||||
func toolViews(c manifest.Contribution) map[string]json.RawMessage {
|
||||
if len(c.ToolViews) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]json.RawMessage, len(c.ToolViews))
|
||||
for name, v := range c.ToolViews {
|
||||
if b, err := json.Marshal(v); err == nil {
|
||||
out[name] = b
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func later(a, b time.Time) time.Time {
|
||||
if b.After(a) {
|
||||
return b
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
mcpgo "github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/mcp"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
||||
)
|
||||
|
||||
// SetInvoker lets the activator reach MCP servers plugins serve themselves
|
||||
// (mcpServers without a url): the MCP client talks to them through plugin
|
||||
// calls, which carry the workspace's context and configuration.
|
||||
func (a *MCPServers) SetInvoker(iv *Invoker) {
|
||||
a.mu.Lock()
|
||||
a.iv = iv
|
||||
a.mu.Unlock()
|
||||
mcp.RegisterTransport(types.MCPTransportPlugin, a.newTransport)
|
||||
}
|
||||
|
||||
func (a *MCPServers) newTransport(svc *types.MCPService) (transport.Interface, error) {
|
||||
a.mu.RLock()
|
||||
iv := a.iv
|
||||
var found *mcpServer
|
||||
for _, s := range a.servers[svc.PluginID] {
|
||||
if s.contrib.ID == svc.PluginServer {
|
||||
s := s
|
||||
found = &s
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if iv == nil || found == nil {
|
||||
return nil, fmt.Errorf("plugin MCP server %s/%s is not loaded", svc.PluginID, svc.PluginServer)
|
||||
}
|
||||
return &pluginTransport{iv: iv, m: found.manifest, server: found.contrib.ID, tenantID: svc.TenantID}, nil
|
||||
}
|
||||
|
||||
// pluginTransport carries MCP messages as plugin calls. Plugin servers are
|
||||
// stateless: there is no session to start or close, and notifications
|
||||
// (initialized, cancelled) need no delivery.
|
||||
type pluginTransport struct {
|
||||
iv *Invoker
|
||||
m *manifest.Manifest
|
||||
server string
|
||||
tenantID uint64
|
||||
}
|
||||
|
||||
func (t *pluginTransport) Start(context.Context) error { return nil }
|
||||
|
||||
func (t *pluginTransport) SendRequest(
|
||||
ctx context.Context, req transport.JSONRPCRequest,
|
||||
) (*transport.JSONRPCResponse, error) {
|
||||
id, err := json.Marshal(req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := pluginapi.MCPRequest{JSONRPC: "2.0", ID: id, Method: req.Method}
|
||||
if req.Params != nil {
|
||||
if in.Params, err = json.Marshal(req.Params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// The service belongs to one workspace; calls run in its context even
|
||||
// when the MCP manager connects without one.
|
||||
ctx = context.WithValue(ctx, types.TenantIDContextKey, t.tenantID)
|
||||
var out pluginapi.MCPResponse
|
||||
if err := t.iv.Call(ctx, t.m, pluginapi.MCPPath(t.server), nil, in, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &transport.JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: out.Result}
|
||||
if out.Error != nil {
|
||||
resp.Error = &mcpgo.JSONRPCErrorDetails{Code: out.Error.Code, Message: out.Error.Message}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (t *pluginTransport) SendNotification(context.Context, mcpgo.JSONRPCNotification) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *pluginTransport) SetNotificationHandler(func(mcpgo.JSONRPCNotification)) {}
|
||||
|
||||
func (t *pluginTransport) Close() error { return nil }
|
||||
|
||||
//nolint:revive // mcp-go's transport.Interface names it so.
|
||||
func (t *pluginTransport) GetSessionId() string { return "" }
|
||||
@@ -0,0 +1,129 @@
|
||||
package activate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/mcp"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/pkg"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/reconcile"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/registry"
|
||||
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/pluginsdk"
|
||||
"github.com/Tencent/WeKnora/pluginsdk/client"
|
||||
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
||||
)
|
||||
|
||||
const toolsManifest = `schemaVersion: 1
|
||||
id: acme.tools
|
||||
version: 1.2.0
|
||||
apiVersion: weknora.plugin/v1
|
||||
name: { en-US: ACME Tools }
|
||||
publisher: { id: acme }
|
||||
runtime: { type: host, kind: binary, entry: bin/tools }
|
||||
config: { tenant: tenant.yaml }
|
||||
contributes:
|
||||
mcpServers:
|
||||
- id: issues
|
||||
name: ACME Issues
|
||||
toolViews:
|
||||
search:
|
||||
view: table
|
||||
items: issues
|
||||
columns: [key, { field: summary, title: { en-US: Summary }, link: url }]
|
||||
`
|
||||
|
||||
// A tool a plugin serves itself is reached through plugin calls: the MCP
|
||||
// client sees an ordinary server, and each call carries the workspace.
|
||||
func TestPluginServedMCPServer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
p := pluginsdk.New(pluginsdk.Info{ID: "acme.tools", Version: "1.2.0"})
|
||||
p.Tool("issues", pluginapi.Tool{Name: "search", Description: "Search issues"},
|
||||
func(_ context.Context, call *pluginsdk.Call, args json.RawMessage) (*pluginapi.ToolResult, error) {
|
||||
var in struct{ Q string }
|
||||
_ = json.Unmarshal(args, &in)
|
||||
return pluginapi.StructuredResult(map[string]any{
|
||||
"issues": []map[string]any{{"key": "ENG-1", "summary": in.Q}},
|
||||
"tenant": call.TenantID, "site": call.Config.Tenant["site"],
|
||||
}, "one issue"), nil
|
||||
})
|
||||
srv := httptest.NewServer(p.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
opened, err := pkg.Open(plugintest.Zip(t, map[string]string{
|
||||
"plugin.yaml": toolsManifest, "bin/tools": "x",
|
||||
"tenant.yaml": "type: object\nproperties: { site: { type: string } }\n",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := registry.New()
|
||||
if err := reg.Register(opened.Manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := &plugintest.MemTenantSettings{}
|
||||
ten := tenancy.NewService(reg, settings)
|
||||
plugins := plugintest.NewMemRepo()
|
||||
_ = settings.Upsert(ctx, &types.PluginTenantSetting{TenantID: 7, PluginID: "acme.tools", Enabled: true})
|
||||
if _, err := ten.SetConfig(ctx, 7, "acme.tools", map[string]any{"site": "acme.example"}, "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
iv := NewInvoker(fakeClients{client.New(srv.URL, nil, nil)})
|
||||
iv.Bind(ten, plugins)
|
||||
a := NewMCPServers()
|
||||
a.Bind(ten, plugins)
|
||||
a.SetInvoker(iv)
|
||||
if err := a.Activate(ctx, &reconcile.Loaded{Manifest: opened.Manifest, Package: opened}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svcs := a.Services(ctx, 7)
|
||||
if len(svcs) != 1 {
|
||||
t.Fatalf("services = %d", len(svcs))
|
||||
}
|
||||
svc := svcs[0]
|
||||
if svc.TransportType != types.MCPTransportPlugin || svc.URL != nil || !svc.Enabled ||
|
||||
svc.PluginVersion != "1.2.0" || !strings.Contains(string(svc.ToolViews["search"]), `"link":"url"`) {
|
||||
t.Fatalf("service = %+v", svc)
|
||||
}
|
||||
|
||||
c, err := mcp.NewMCPClient(&mcp.ClientConfig{Service: svc})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The manager connects without a workspace in the context.
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Initialize(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools, err := c.ListTools(ctx)
|
||||
if err != nil || len(tools) != 1 || tools[0].Name != "search" {
|
||||
t.Fatalf("tools = %+v, %v", tools, err)
|
||||
}
|
||||
res, err := c.CallTool(ctx, "search", map[string]any{"q": "login"})
|
||||
if err != nil || res.IsError || res.Content[0].Text != "one issue" {
|
||||
t.Fatalf("call = %+v, %v", res, err)
|
||||
}
|
||||
got, _ := json.Marshal(res.StructuredContent)
|
||||
if string(got) != `{"issues":[{"key":"ENG-1","summary":"login"}],"site":"acme.example","tenant":7}` {
|
||||
t.Fatalf("structured = %s", got)
|
||||
}
|
||||
|
||||
// Another workspace does not see a plugin it has not switched on.
|
||||
if len(a.Services(ctx, 8)) != 0 {
|
||||
t.Fatal("tenant 8 sees the plugin's server")
|
||||
}
|
||||
if err := a.Deactivate(ctx, "acme.tools"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := mcp.NewMCPClient(&mcp.ClientConfig{Service: svc}); err == nil {
|
||||
t.Fatal("an unloaded plugin's server still connects")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
var ErrNoPage = errors.New("no such plugin page")
|
||||
|
||||
// UIPages keeps the pages of loaded plugins (pages, settingsSections,
|
||||
// kbTabs): where their files are, and which mounts exist.
|
||||
// kbTabs, and tool result pages of mcpServers): where their files are, and
|
||||
// which mounts exist.
|
||||
type UIPages struct {
|
||||
mu sync.RWMutex
|
||||
loaded map[string]uiPlugin
|
||||
@@ -37,8 +38,11 @@ func (a *UIPages) Name() string { return "ui" }
|
||||
// Activate implements reconcile.Activator.
|
||||
func (a *UIPages) Activate(_ context.Context, l *reconcile.Loaded) error {
|
||||
has := false
|
||||
for point := range l.Manifest.Contributes {
|
||||
for point, list := range l.Manifest.Contributes {
|
||||
has = has || manifest.IsUIPoint(point)
|
||||
for _, c := range list {
|
||||
has = has || (point == manifest.PointMCPServers && c.HasToolPages())
|
||||
}
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -98,11 +102,13 @@ func (a *UIPages) Mount(pluginID, mount string) (Mount, error) {
|
||||
p, ok := a.loaded[pluginID]
|
||||
a.mu.RUnlock()
|
||||
point, id, found := strings.Cut(mount, "/")
|
||||
if !ok || !found || !manifest.IsUIPoint(manifest.Point(point)) {
|
||||
toolPages := manifest.Point(point) == manifest.PointMCPServers
|
||||
if !ok || !found || (!manifest.IsUIPoint(manifest.Point(point)) && !toolPages) {
|
||||
return Mount{}, ErrNoPage
|
||||
}
|
||||
for _, c := range p.m.Contributes[manifest.Point(point)] {
|
||||
if c.ID == id {
|
||||
// A tool result page answers to its server: "mcpServers/<id>".
|
||||
if c.ID == id && (!toolPages || c.HasToolPages()) {
|
||||
return Mount{Manifest: p.m, Point: manifest.Point(point), Contribution: c}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,15 @@ func CheckServedManifest(want *manifest.Manifest, got *pluginapi.Manifest) error
|
||||
if len(want.Permissions.Events) > 0 && len(got.Contributes["events"]) == 0 {
|
||||
return errors.New("plugin.yaml subscribes to events but the plugin handles none")
|
||||
}
|
||||
servesMCP := map[string]bool{}
|
||||
for _, id := range got.Contributes[string(manifest.PointMCPServers)] {
|
||||
servesMCP[id] = true
|
||||
}
|
||||
for _, c := range want.Contributes[manifest.PointMCPServers] {
|
||||
if c.ServedByPlugin() && !servesMCP[c.ID] {
|
||||
return fmt.Errorf("plugin.yaml declares the MCP server %s but the plugin does not serve it", c.ID)
|
||||
}
|
||||
}
|
||||
for point, contribs := range want.Contributes {
|
||||
if info, ok := manifest.LookupPoint(point); !ok || info.Declarative {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/plugin/manifest"
|
||||
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
||||
)
|
||||
|
||||
func TestServedManifestCoversOwnMCPServers(t *testing.T) {
|
||||
want := &manifest.Manifest{ID: "acme.tools", Version: "1.0.0", Contributes: manifest.Contributions{
|
||||
manifest.PointMCPServers: {
|
||||
{ID: "issues"},
|
||||
{ID: "remote", MCP: &manifest.MCPServer{URL: "https://mcp.example.com"}},
|
||||
},
|
||||
}}
|
||||
got := &pluginapi.Manifest{
|
||||
ID: "acme.tools", Version: "1.0.0", APIVersion: pluginapi.APIVersion,
|
||||
Contributes: map[string][]string{},
|
||||
}
|
||||
if err := CheckServedManifest(want, got); err == nil || !strings.Contains(err.Error(), "MCP server issues") {
|
||||
t.Fatalf("missing server = %v", err)
|
||||
}
|
||||
// Remote servers are not the plugin's to serve.
|
||||
got.Contributes["mcpServers"] = []string{"issues"}
|
||||
if err := CheckServedManifest(want, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -151,8 +151,12 @@ type Contribution struct {
|
||||
// Path is a file or directory inside the package: the skill directory
|
||||
// (skills) or the vendor definition JSON (modelVendors).
|
||||
Path string `json:"path,omitempty" yaml:"path"`
|
||||
// MCP describes a remote MCP server (mcpServers).
|
||||
// MCP describes a remote MCP server (mcpServers). A plugin with code
|
||||
// may leave it out and serve the tools itself (pluginapi.MCPPath).
|
||||
MCP *MCPServer `json:"mcp,omitempty" yaml:"mcp"`
|
||||
// ToolViews map tool names of an MCP server to the view that shows
|
||||
// their structured results in the chat.
|
||||
ToolViews map[string]ToolView `json:"toolViews,omitempty" yaml:"toolViews"`
|
||||
// FileTypes are the lower-case extensions a parser handles ("pdf").
|
||||
FileTypes []string `json:"fileTypes,omitempty" yaml:"fileTypes"`
|
||||
// InstanceSchemaJSON is the instanceSchema file's content as JSON,
|
||||
@@ -403,7 +407,13 @@ func (m *Manifest) validateContributions(add func(string, ...any)) {
|
||||
if len(c.Aliases) > 0 && !m.Builtin {
|
||||
add("%s.aliases may only be declared by builtin plugins", where)
|
||||
}
|
||||
validateDeclarative(point, c, m.Builtin, where, add)
|
||||
validateDeclarative(point, c, m.Builtin, m.Runtime.Type != RuntimeDeclarative, where, add)
|
||||
if len(c.ToolViews) > 0 {
|
||||
if point != PointMCPServers {
|
||||
add("%s.toolViews belong to mcpServers", where)
|
||||
}
|
||||
validateToolViews(c.ToolViews, where, add)
|
||||
}
|
||||
if IsUIPoint(point) {
|
||||
validateUI(c, where, add)
|
||||
}
|
||||
@@ -437,7 +447,7 @@ func validateFileTypes(types []string, where string, add func(string, ...any)) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateDeclarative(point Point, c Contribution, builtin bool, where string, add func(string, ...any)) {
|
||||
func validateDeclarative(point Point, c Contribution, builtin, code bool, where string, add func(string, ...any)) {
|
||||
if builtin {
|
||||
return
|
||||
}
|
||||
@@ -449,8 +459,12 @@ func validateDeclarative(point Point, c Contribution, builtin bool, where string
|
||||
add("%s.path %q must be a relative path inside the package", where, c.Path)
|
||||
}
|
||||
case PointMCPServers:
|
||||
if c.MCP == nil || c.MCP.URL == "" {
|
||||
add("%s.mcp.url is required", where)
|
||||
if c.ServedByPlugin() {
|
||||
if !code {
|
||||
add("%s.mcp.url is required: only a plugin with code can serve tools itself", where)
|
||||
} else if c.MCP != nil && (c.MCP.Transport != "" || len(c.MCP.Headers) > 0) {
|
||||
add("%s.mcp: transport and headers only apply to a remote url", where)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(c.MCP.URL, "https://") && !strings.HasPrefix(c.MCP.URL, "http://") {
|
||||
|
||||
@@ -2,6 +2,7 @@ package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -265,3 +266,54 @@ func TestResourceAmounts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolServersAndViews(t *testing.T) {
|
||||
base := `schemaVersion: 1
|
||||
id: acme.tools
|
||||
version: 1.0.0
|
||||
apiVersion: weknora.plugin/v1
|
||||
name: { en-US: Tools }
|
||||
publisher: { id: acme }
|
||||
runtime: %s
|
||||
contributes:
|
||||
mcpServers:
|
||||
- id: issues
|
||||
name: Issues
|
||||
%s`
|
||||
parse := func(runtime, rest string) error {
|
||||
_, err := Parse([]byte(fmt.Sprintf(base, runtime, rest)))
|
||||
return err
|
||||
}
|
||||
code := "{ type: host, kind: binary, entry: bin/x }"
|
||||
views := ` toolViews:
|
||||
search: { view: table, items: issues, columns: [key, { field: summary, title: { en-US: Summary }, link: url }] }
|
||||
get: { view: page, entry: ui/issue.html }
|
||||
stats: { view: kv }
|
||||
`
|
||||
if err := parse(code, views); err != nil {
|
||||
t.Fatalf("a code plugin serving its own tools: %v", err)
|
||||
}
|
||||
m, _ := Parse([]byte(fmt.Sprintf(base, code, views)))
|
||||
c := m.Contributes[PointMCPServers][0]
|
||||
if !c.ServedByPlugin() || c.ToolViews["search"].Columns[0].Field != "key" ||
|
||||
c.ToolViews["search"].Columns[1].Link != "url" {
|
||||
t.Fatalf("contribution = %+v", c)
|
||||
}
|
||||
for name, tc := range map[string]struct{ runtime, rest, want string }{
|
||||
"declarative needs a url": {"{ type: declarative }", "", "mcp.url is required"},
|
||||
"headers need a url": {code, " mcp: { headers: { X: y } }\n", "only apply to a remote url"},
|
||||
"table without columns": {code, " toolViews: { search: { view: table } }\n", "a table needs columns"},
|
||||
"cards without title": {code, " toolViews: { search: { view: cards } }\n", "cards need a title"},
|
||||
"page outside ui": {
|
||||
code, " toolViews: { get: { view: page, entry: x.html } }\n", "must be a file under ui/",
|
||||
},
|
||||
"unknown view": {code, " toolViews: { get: { view: chart } }\n", "view must be"},
|
||||
"bad path": {
|
||||
code, " toolViews: { get: { view: kv, title: \"a[0]\" } }\n", "dotted field path",
|
||||
},
|
||||
} {
|
||||
if err := parse(tc.runtime, tc.rest); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ServedByPlugin reports whether an mcpServers contribution is served by
|
||||
// the plugin's own process (declared without mcp.url) rather than a remote
|
||||
// MCP endpoint.
|
||||
func (c Contribution) ServedByPlugin() bool { return c.MCP == nil || c.MCP.URL == "" }
|
||||
|
||||
// HasToolPages reports whether an mcpServers contribution shows some tool
|
||||
// results in one of the plugin's pages.
|
||||
func (c Contribution) HasToolPages() bool {
|
||||
for _, v := range c.ToolViews {
|
||||
if v.View == ToolViewPage {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Tool result views: how the chat shows a tool's structured result.
|
||||
const (
|
||||
ToolViewTable = "table"
|
||||
ToolViewCards = "cards"
|
||||
ToolViewKV = "kv"
|
||||
ToolViewMarkdown = "markdown"
|
||||
ToolViewJSON = "json"
|
||||
// ToolViewPage renders the result in one of the plugin's pages.
|
||||
ToolViewPage = "page"
|
||||
)
|
||||
|
||||
// ToolView maps a tool's structuredContent to a result view the chat
|
||||
// renders; plugins bring no frontend code. Paths are dotted field paths
|
||||
// into the structured content ("issues", "fields.status"); an empty Items
|
||||
// path means the content itself.
|
||||
type ToolView struct {
|
||||
View string `json:"view" yaml:"view"`
|
||||
// Items is the list a table or cards show.
|
||||
Items string `json:"items,omitempty" yaml:"items"`
|
||||
// Columns of a table, in order.
|
||||
Columns []ToolViewColumn `json:"columns,omitempty" yaml:"columns"`
|
||||
// Title, Subtitle, Body and Link name the fields of each card; Title
|
||||
// and Link also head a kv view.
|
||||
Title string `json:"title,omitempty" yaml:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty" yaml:"subtitle"`
|
||||
Body string `json:"body,omitempty" yaml:"body"`
|
||||
Link string `json:"link,omitempty" yaml:"link"`
|
||||
// Fields are the rows of a kv view (all top-level fields when empty).
|
||||
Fields []ToolViewColumn `json:"fields,omitempty" yaml:"fields"`
|
||||
// Field holds the Markdown of a markdown view (the text content when
|
||||
// empty).
|
||||
Field string `json:"field,omitempty" yaml:"field"`
|
||||
// Entry is the page of a page view, an .html file under ui/. It gets
|
||||
// the tool's arguments and result when it starts.
|
||||
Entry string `json:"entry,omitempty" yaml:"entry"`
|
||||
}
|
||||
|
||||
// ToolViewColumn is one table column or kv row: a field, its label, and
|
||||
// optionally the field holding a URL to link it to.
|
||||
type ToolViewColumn struct {
|
||||
Field string `json:"field" yaml:"field"`
|
||||
Title LocalizedText `json:"title,omitzero" yaml:"title"`
|
||||
Link string `json:"link,omitempty" yaml:"link"`
|
||||
}
|
||||
|
||||
// UnmarshalYAML accepts a bare field name as shorthand.
|
||||
func (c *ToolViewColumn) UnmarshalYAML(n *yaml.Node) error {
|
||||
if n.Kind == yaml.ScalarNode {
|
||||
c.Field = n.Value
|
||||
return nil
|
||||
}
|
||||
type plain ToolViewColumn
|
||||
return n.Decode((*plain)(c))
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts a bare field name as shorthand.
|
||||
func (c *ToolViewColumn) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if json.Unmarshal(b, &s) == nil {
|
||||
c.Field = s
|
||||
return nil
|
||||
}
|
||||
type plain ToolViewColumn
|
||||
return json.Unmarshal(b, (*plain)(c))
|
||||
}
|
||||
|
||||
var (
|
||||
toolNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`)
|
||||
fieldPathPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
|
||||
)
|
||||
|
||||
func validateToolViews(views map[string]ToolView, where string, add func(string, ...any)) {
|
||||
names := make([]string, 0, len(views))
|
||||
for name := range views {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
v := views[name]
|
||||
at := fmt.Sprintf("%s.toolViews.%s", where, name)
|
||||
if !toolNamePattern.MatchString(name) {
|
||||
add("%s: %q is not a tool name", at, name)
|
||||
}
|
||||
path := func(label, p string) {
|
||||
if p != "" && !fieldPathPattern.MatchString(p) {
|
||||
add("%s.%s %q must be a dotted field path", at, label, p)
|
||||
}
|
||||
}
|
||||
path("items", v.Items)
|
||||
for _, p := range []struct{ label, value string }{
|
||||
{"title", v.Title}, {"subtitle", v.Subtitle}, {"body", v.Body}, {"link", v.Link}, {"field", v.Field},
|
||||
} {
|
||||
path(p.label, p.value)
|
||||
}
|
||||
for i, c := range append(append([]ToolViewColumn{}, v.Columns...), v.Fields...) {
|
||||
if c.Field == "" {
|
||||
add("%s: column %d needs a field", at, i)
|
||||
}
|
||||
path("field", c.Field)
|
||||
path("link", c.Link)
|
||||
}
|
||||
switch v.View {
|
||||
case ToolViewTable:
|
||||
if len(v.Columns) == 0 {
|
||||
add("%s: a table needs columns", at)
|
||||
}
|
||||
case ToolViewCards:
|
||||
if v.Title == "" {
|
||||
add("%s: cards need a title field", at)
|
||||
}
|
||||
case ToolViewKV, ToolViewMarkdown, ToolViewJSON:
|
||||
case ToolViewPage:
|
||||
switch {
|
||||
case !isPackagePath(v.Entry) || !strings.HasPrefix(v.Entry, UIRoot):
|
||||
add("%s.entry %q must be a file under %s", at, v.Entry, UIRoot)
|
||||
case !strings.HasSuffix(v.Entry, ".html"):
|
||||
add("%s.entry %q must be an .html file", at, v.Entry)
|
||||
}
|
||||
default:
|
||||
add("%s.view must be table, cards, kv, markdown, json or page", at)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,11 @@ func (p *Package) checkReferences() error {
|
||||
if manifest.IsUIPoint(info.Point) && c.Entry != "" {
|
||||
need("page", c.Entry)
|
||||
}
|
||||
for _, v := range c.ToolViews {
|
||||
if v.View == manifest.ToolViewPage {
|
||||
need("tool result page", v.Entry)
|
||||
}
|
||||
}
|
||||
if c.Icon != "" {
|
||||
need("icon", c.Icon)
|
||||
}
|
||||
|
||||
+15
-4
@@ -19,6 +19,9 @@ const (
|
||||
MCPTransportSSE MCPTransportType = "sse" // Server-Sent Events
|
||||
MCPTransportHTTPStreamable MCPTransportType = "http-streamable" // HTTP Streamable
|
||||
MCPTransportStdio MCPTransportType = "stdio" // Stdio (Standard Input/Output)
|
||||
// MCPTransportPlugin is an MCP server a plugin serves itself, reached
|
||||
// through plugin calls instead of a URL.
|
||||
MCPTransportPlugin MCPTransportType = "plugin"
|
||||
)
|
||||
|
||||
// MCPService represents an MCP (Model Context Protocol) service configuration
|
||||
@@ -44,10 +47,18 @@ type MCPService struct {
|
||||
PluginID string `json:"plugin_id,omitempty" gorm:"-"`
|
||||
// PluginError says why a plugin service is disabled, typically that the
|
||||
// workspace has not configured the plugin yet.
|
||||
PluginError string `json:"plugin_error,omitempty" gorm:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
PluginError string `json:"plugin_error,omitempty" gorm:"-"`
|
||||
// PluginVersion is the version of the plugin providing the service.
|
||||
PluginVersion string `json:"-" gorm:"-"`
|
||||
// PluginServer is the plugin's local ID of the server (its mcpServers
|
||||
// contribution); the plugin transport calls it by this ID.
|
||||
PluginServer string `json:"-" gorm:"-"`
|
||||
// ToolViews are the plugin's result views by tool name (manifest
|
||||
// ToolView as JSON), for the chat to render structured results.
|
||||
ToolViews map[string]json.RawMessage `json:"-" gorm:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
||||
|
||||
// EffectiveUsageInstructions preserves documentation on legacy services until
|
||||
|
||||
+60
-1
@@ -151,6 +151,64 @@ WeKnora answers 404 unless all of these hold:
|
||||
|
||||
Bodies are limited to 1 MB, with 20 calls a second per URL.
|
||||
|
||||
## Agent tools
|
||||
|
||||
Agent tools are MCP tools. A plugin with code can serve them itself: declare
|
||||
an `mcpServers` contribution without `mcp.url`, then add the tools.
|
||||
|
||||
```yaml
|
||||
contributes:
|
||||
mcpServers:
|
||||
- id: tools
|
||||
name: { en-US: ACME Issues }
|
||||
toolViews: # optional: how the chat shows structured results
|
||||
search_issues:
|
||||
view: table
|
||||
items: issues
|
||||
columns: [{ field: key, link: url }, summary, status]
|
||||
```
|
||||
|
||||
```go
|
||||
p.Tool("tools", pluginapi.Tool{
|
||||
Name: "search_issues",
|
||||
Description: "Search issues",
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}}}`),
|
||||
}, func(ctx context.Context, call *pluginsdk.Call, args json.RawMessage) (*pluginapi.ToolResult, error) {
|
||||
// call.Config.Tenant is the workspace's plugin configuration.
|
||||
return pluginapi.StructuredResult(map[string]any{"issues": rows}, "3 issues ..."), nil
|
||||
})
|
||||
```
|
||||
|
||||
How it works:
|
||||
- **Discovery.** Every workspace that switches the plugin on gets the server
|
||||
as a read-only MCP service. Agents use it like any MCP service, with the
|
||||
same per-tool approval settings.
|
||||
- **Transport.** Messages reach the plugin at `/v1/mcp/{id}` inside the usual
|
||||
envelope. Each call therefore carries the workspace's context and
|
||||
configuration (OAuth fields already swapped for tokens), and the server
|
||||
keeps no session.
|
||||
- **Results.** The model reads the text content. Return failures the model
|
||||
should see as `pluginapi.ToolError` (or any error); they become results with
|
||||
`isError`.
|
||||
- **`toolViews`** map a tool's `structuredContent` to a view, so the chat can
|
||||
show it without plugin code:
|
||||
|
||||
| View | Shows | Fields |
|
||||
| --- | --- | --- |
|
||||
| `table` | A list as rows | `items`, `columns` (field, title, link) |
|
||||
| `cards` | A list as cards | `items`, `title`, `subtitle`, `body`, `link` |
|
||||
| `kv` | One object's fields | `items`, `title`, `link`, `fields` |
|
||||
| `markdown` | Rendered Markdown | `field` (the text content when empty) |
|
||||
| `json` | The data, formatted | - |
|
||||
| `page` | A page of the plugin (`entry` under `ui/`) | Gets `{tool, arguments, result}` as its context |
|
||||
|
||||
Paths are dotted field paths into the structured content. A page's
|
||||
requests use the mount `mcpServers/<id>`.
|
||||
|
||||
A plugin can also point `mcp.url` at an MCP server it runs elsewhere; then
|
||||
headers carry the configuration (`${config.<key>}`), and `toolViews` apply
|
||||
too.
|
||||
|
||||
## Dynamic choices and OAuth
|
||||
|
||||
Two schema keywords let a form ask the plugin while someone fills it in.
|
||||
@@ -280,7 +338,8 @@ Complete plugins with their `package.sh`:
|
||||
- `examples/plugins/links`: pages (toolbox, settings, knowledge base tab), in Python.
|
||||
- `examples/plugins/activity`: events, a webhook and a page, in Python.
|
||||
- `examples/plugins/jira`: a Jira Cloud connector with an OAuth field, dynamic
|
||||
options, incremental sync with deletions, and a skill.
|
||||
options, incremental sync with deletions; agent tools with result views; and
|
||||
a skill.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -170,6 +170,41 @@ func Run(ctx context.Context, t Target) Report {
|
||||
return protocolAnswer(err)
|
||||
})
|
||||
}
|
||||
for _, id := range m.Contributes["mcpServers"] {
|
||||
check("mcp/"+id+" lists its tools", func(ctx context.Context) error {
|
||||
var init, list pluginapi.MCPResponse
|
||||
params := `{"protocolVersion":"` + pluginapi.MCPProtocolVersion + `","capabilities":{},` +
|
||||
`"clientInfo":{"name":"conformance","version":"1"}}`
|
||||
req := pluginapi.MCPRequest{
|
||||
JSONRPC: "2.0", ID: json.RawMessage(`1`), Method: "initialize", Params: json.RawMessage(params),
|
||||
}
|
||||
if err := t.Client.Call(ctx, pluginapi.MCPPath(id), envelope(), req, &init); err != nil {
|
||||
return err
|
||||
}
|
||||
if init.Error != nil || len(init.Result) == 0 {
|
||||
return fmt.Errorf("initialize failed: %+v", init.Error)
|
||||
}
|
||||
req = pluginapi.MCPRequest{JSONRPC: "2.0", ID: json.RawMessage(`2`), Method: "tools/list"}
|
||||
if err := t.Client.Call(ctx, pluginapi.MCPPath(id), envelope(), req, &list); err != nil {
|
||||
return err
|
||||
}
|
||||
var tools struct {
|
||||
Tools []pluginapi.Tool `json:"tools"`
|
||||
}
|
||||
if list.Error != nil || json.Unmarshal(list.Result, &tools) != nil || len(tools.Tools) == 0 {
|
||||
return fmt.Errorf("tools/list answered no tools")
|
||||
}
|
||||
for _, tool := range tools.Tools {
|
||||
var schema struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if tool.Name == "" || json.Unmarshal(tool.InputSchema, &schema) != nil || schema.Type != "object" {
|
||||
return fmt.Errorf("tool %q needs a name and an object inputSchema", tool.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
for _, id := range m.Contributes["webhooks"] {
|
||||
check("webhooks/"+id+" answers", func(ctx context.Context) error {
|
||||
var out pluginapi.WebhookResponse
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package pluginsdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
|
||||
)
|
||||
|
||||
// ToolHandler runs one tool call. args are the arguments as the model sent
|
||||
// them; decode them into the shape of the tool's input schema. Return
|
||||
// pluginapi.ToolError (or any error) for failures the model should see.
|
||||
type ToolHandler func(ctx context.Context, call *Call, args json.RawMessage) (*pluginapi.ToolResult, error)
|
||||
|
||||
type mcpServer struct {
|
||||
tools []pluginapi.Tool
|
||||
handlers map[string]ToolHandler
|
||||
}
|
||||
|
||||
// Tool adds a tool to the MCP server the plugin serves as server
|
||||
// (contributes.mcpServers[].id, declared without a url). Agents in the
|
||||
// workspaces that switched the plugin on can call it like any MCP tool,
|
||||
// with the same approval settings; call carries the workspace's
|
||||
// configuration.
|
||||
func (p *Plugin) Tool(server string, tool pluginapi.Tool, h ToolHandler) {
|
||||
s := p.mcp[server]
|
||||
if s == nil {
|
||||
s = &mcpServer{handlers: map[string]ToolHandler{}}
|
||||
p.mcp[server] = s
|
||||
}
|
||||
if len(tool.InputSchema) == 0 {
|
||||
tool.InputSchema = json.RawMessage(`{"type":"object"}`)
|
||||
}
|
||||
if _, dup := s.handlers[tool.Name]; !dup {
|
||||
s.tools = append(s.tools, tool)
|
||||
}
|
||||
s.handlers[tool.Name] = h
|
||||
}
|
||||
|
||||
func (p *Plugin) routeMCP(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /v1/mcp/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
s, ok := p.mcp[id]
|
||||
if !ok {
|
||||
writeError(w, pluginapi.Errorf(pluginapi.CodeNotFound, "no MCP server %q", id))
|
||||
return
|
||||
}
|
||||
p.unary(func(ctx context.Context, call *Call, raw json.RawMessage) (any, error) {
|
||||
req, err := decodeInput[pluginapi.MCPRequest](raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.answerMCP(ctx, call, id, s, req), nil
|
||||
})(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Plugin) answerMCP(
|
||||
ctx context.Context, call *Call, id string, s *mcpServer, req pluginapi.MCPRequest,
|
||||
) pluginapi.MCPResponse {
|
||||
resp := pluginapi.MCPResponse{JSONRPC: "2.0", ID: req.ID}
|
||||
result := func(v any) pluginapi.MCPResponse {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
resp.Error = &pluginapi.MCPError{Code: -32603, Message: err.Error()}
|
||||
return resp
|
||||
}
|
||||
resp.Result = b
|
||||
return resp
|
||||
}
|
||||
fail := func(code int, msg string) pluginapi.MCPResponse {
|
||||
resp.Error = &pluginapi.MCPError{Code: code, Message: msg}
|
||||
return resp
|
||||
}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return result(map[string]any{
|
||||
"protocolVersion": pluginapi.MCPProtocolVersion,
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": p.info.ID + "/" + id, "version": p.info.Version},
|
||||
})
|
||||
case "ping":
|
||||
return result(struct{}{})
|
||||
// Tools only: clients that list resources or prompts anyway get none.
|
||||
case "resources/list":
|
||||
return result(map[string]any{"resources": []any{}})
|
||||
case "resources/templates/list":
|
||||
return result(map[string]any{"resourceTemplates": []any{}})
|
||||
case "prompts/list":
|
||||
return result(map[string]any{"prompts": []any{}})
|
||||
case "tools/list":
|
||||
tools := append([]pluginapi.Tool(nil), s.tools...)
|
||||
sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name })
|
||||
return result(map[string]any{"tools": tools})
|
||||
case "tools/call":
|
||||
var params pluginapi.ToolCallParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return fail(pluginapi.MCPInvalidParams, "params: "+err.Error())
|
||||
}
|
||||
h, ok := s.handlers[params.Name]
|
||||
if !ok || h == nil {
|
||||
return fail(pluginapi.MCPInvalidParams, "no tool "+params.Name)
|
||||
}
|
||||
args := params.Arguments
|
||||
if len(args) == 0 || string(args) == "null" {
|
||||
args = json.RawMessage(`{}`)
|
||||
}
|
||||
out, err := h(ctx, call, args)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if pe, ok := pluginapi.AsError(err); ok {
|
||||
msg = pe.Message
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
msg = "the call was cancelled"
|
||||
}
|
||||
out = pluginapi.ToolError("%s", msg)
|
||||
}
|
||||
if out == nil {
|
||||
out = pluginapi.TextResult("")
|
||||
}
|
||||
if out.Content == nil {
|
||||
out.Content = []pluginapi.ToolContent{}
|
||||
}
|
||||
return result(out)
|
||||
default:
|
||||
if len(req.ID) == 0 {
|
||||
// Notifications need no answer; the server keeps no state.
|
||||
return resp
|
||||
}
|
||||
return fail(pluginapi.MCPMethodNotFound, "method "+req.Method+" is not supported")
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ func TestOpenAPIMatchesRoutes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"websearch.go", "connector.go", "parser.go", "ui.go", "events.go", "options.go"} {
|
||||
files := []string{"websearch.go", "connector.go", "parser.go", "ui.go", "events.go", "options.go", "mcp.go"}
|
||||
for _, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -67,6 +67,7 @@ type Plugin struct {
|
||||
events EventHandler
|
||||
webhooks map[string]WebhookHandler
|
||||
options map[string]OptionsHandler
|
||||
mcp map[string]*mcpServer
|
||||
validate ConfigValidator
|
||||
logger *slog.Logger
|
||||
// ShutdownTimeout bounds how long Serve waits for calls in flight after
|
||||
@@ -83,6 +84,7 @@ func New(info Info) *Plugin {
|
||||
parsers: map[string]Parser{},
|
||||
webhooks: map[string]WebhookHandler{},
|
||||
options: map[string]OptionsHandler{},
|
||||
mcp: map[string]*mcpServer{},
|
||||
logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
|
||||
ShutdownTimeout: 60 * time.Second,
|
||||
}
|
||||
@@ -111,6 +113,7 @@ func (p *Plugin) Manifest() pluginapi.Manifest {
|
||||
add("parsers", keys(p.parsers))
|
||||
add("webhooks", keys(p.webhooks))
|
||||
add("options", keys(p.options))
|
||||
add("mcpServers", keys(p.mcp))
|
||||
if p.events != nil {
|
||||
m.Contributes["events"] = []string{"handler"}
|
||||
}
|
||||
@@ -155,6 +158,7 @@ func (p *Plugin) Handler() http.Handler {
|
||||
p.routeUI(mux)
|
||||
p.routeEvents(mux)
|
||||
p.routeOptions(mux)
|
||||
p.routeMCP(mux)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, pluginapi.Errorf(pluginapi.CodeNotFound, "no endpoint %s %s", r.Method, r.URL.Path))
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package pluginsdk
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
@@ -372,3 +373,77 @@ func TestOptions(t *testing.T) {
|
||||
t.Fatalf("options = %+v, %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTools(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
p := New(Info{ID: "acme.tools", Version: "1.0.0"})
|
||||
p.Tool("issues", pluginapi.Tool{
|
||||
Name: "search", Description: "Search issues",
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`),
|
||||
}, func(_ context.Context, call *Call, args json.RawMessage) (*pluginapi.ToolResult, error) {
|
||||
var in struct{ Q string }
|
||||
_ = json.Unmarshal(args, &in)
|
||||
if in.Q == "boom" {
|
||||
return nil, pluginapi.Errorf(pluginapi.CodeUnauthorized, "token expired")
|
||||
}
|
||||
return pluginapi.StructuredResult(map[string]any{"q": in.Q, "site": call.Config.Tenant["site"]}, ""), nil
|
||||
})
|
||||
p.Tool("issues", pluginapi.Tool{Name: "count", Description: "Count"}, nil)
|
||||
if m := p.Manifest(); len(m.Contributes["mcpServers"]) != 1 || m.Contributes["mcpServers"][0] != "issues" {
|
||||
t.Fatalf("manifest = %+v", m)
|
||||
}
|
||||
srv := httptest.NewServer(p.Handler())
|
||||
defer srv.Close()
|
||||
c := client.New(srv.URL, nil, nil)
|
||||
env := pluginapi.Envelope{Config: pluginapi.Config{Tenant: map[string]any{"site": "acme"}}}
|
||||
send := func(id, method, params string) pluginapi.MCPResponse {
|
||||
t.Helper()
|
||||
req := pluginapi.MCPRequest{JSONRPC: "2.0", Method: method}
|
||||
if id != "" {
|
||||
req.ID = json.RawMessage(id)
|
||||
}
|
||||
if params != "" {
|
||||
req.Params = json.RawMessage(params)
|
||||
}
|
||||
var out pluginapi.MCPResponse
|
||||
if err := c.Call(ctx, pluginapi.MCPPath("issues"), env, req, &out); err != nil {
|
||||
t.Fatalf("%s: %v", method, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if r := send("1", "initialize", `{"protocolVersion":"2025-06-18"}`); r.Error != nil ||
|
||||
!strings.Contains(string(r.Result), `"tools"`) || string(r.ID) != "1" {
|
||||
t.Fatalf("initialize = %+v", r)
|
||||
}
|
||||
var list struct{ Tools []pluginapi.Tool }
|
||||
if r := send("2", "tools/list", ""); json.Unmarshal(r.Result, &list) != nil || len(list.Tools) != 2 ||
|
||||
list.Tools[0].Name != "count" || string(list.Tools[0].InputSchema) != `{"type":"object"}` {
|
||||
t.Fatalf("tools/list = %s", r.Result)
|
||||
}
|
||||
var res pluginapi.ToolResult
|
||||
r := send(`"a"`, "tools/call", `{"name":"search","arguments":{"q":"login"}}`)
|
||||
if json.Unmarshal(r.Result, &res) != nil || res.IsError || res.Content[0].Text != `{"q":"login","site":"acme"}` {
|
||||
t.Fatalf("tools/call = %s", r.Result)
|
||||
}
|
||||
r = send("3", "tools/call", `{"name":"search","arguments":{"q":"boom"}}`)
|
||||
if json.Unmarshal(r.Result, &res) != nil || !res.IsError || res.Content[0].Text != "token expired" {
|
||||
t.Fatalf("a failing tool = %s", r.Result)
|
||||
}
|
||||
if r := send("4", "tools/call", `{"name":"nope"}`); r.Error == nil || r.Error.Code != pluginapi.MCPInvalidParams {
|
||||
t.Fatalf("unknown tool = %+v", r)
|
||||
}
|
||||
if r := send("5", "resources/list", ""); r.Error != nil || string(r.Result) != `{"resources":[]}` {
|
||||
t.Fatalf("resources = %+v", r)
|
||||
}
|
||||
if r := send("6", "completion/complete", ""); r.Error == nil || r.Error.Code != pluginapi.MCPMethodNotFound {
|
||||
t.Fatalf("unsupported method = %+v", r)
|
||||
}
|
||||
if r := send("", "notifications/initialized", ""); r.Error != nil || len(r.Result) != 0 {
|
||||
t.Fatalf("notification = %+v", r)
|
||||
}
|
||||
err := c.Call(ctx, pluginapi.MCPPath("nope"), env, pluginapi.MCPRequest{JSONRPC: "2.0", Method: "ping"}, nil)
|
||||
if !isCode(err, pluginapi.CodeNotFound) {
|
||||
t.Fatalf("unknown server = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package pluginapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MCPPath is the MCP server a plugin serves itself: an mcpServers
|
||||
// contribution without a url. The input is one JSON-RPC 2.0 message of the
|
||||
// Model Context Protocol, the output its JSON-RPC response. Carrying MCP in
|
||||
// the envelope gives tool calls the workspace's context and configuration,
|
||||
// and the same authentication as every other call. Servers are stateless:
|
||||
// each message stands alone, with no session.
|
||||
func MCPPath(id string) string { return "/v1/mcp/" + id }
|
||||
|
||||
// MCPProtocolVersion is the MCP revision plugins speak.
|
||||
const MCPProtocolVersion = "2025-06-18"
|
||||
|
||||
// JSON-RPC error codes MCP servers answer with.
|
||||
const (
|
||||
MCPMethodNotFound = -32601
|
||||
MCPInvalidParams = -32602
|
||||
)
|
||||
|
||||
// MCPRequest is a JSON-RPC request or notification (no ID).
|
||||
type MCPRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// MCPResponse is a JSON-RPC response.
|
||||
type MCPResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *MCPError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// MCPError is a JSON-RPC error.
|
||||
type MCPError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Tool describes a tool for tools/list.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description"`
|
||||
// InputSchema is the JSON Schema of the arguments (type: object).
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
// OutputSchema describes StructuredContent, when the tool returns it.
|
||||
OutputSchema json.RawMessage `json:"outputSchema,omitempty"`
|
||||
Annotations *ToolAnnotations `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// ToolAnnotations are hints about a tool's behavior.
|
||||
type ToolAnnotations struct {
|
||||
// ReadOnlyHint says the tool changes nothing.
|
||||
ReadOnlyHint bool `json:"readOnlyHint,omitempty"`
|
||||
// DestructiveHint says the tool may delete or overwrite.
|
||||
DestructiveHint *bool `json:"destructiveHint,omitempty"`
|
||||
}
|
||||
|
||||
// ToolCallParams are the params of tools/call.
|
||||
type ToolCallParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// ToolResult is what tools/call returns. The model reads Content;
|
||||
// StructuredContent is data for the tool's result view (toolViews).
|
||||
type ToolResult struct {
|
||||
Content []ToolContent `json:"content"`
|
||||
StructuredContent any `json:"structuredContent,omitempty"`
|
||||
// IsError marks a failure the model should see and may recover from.
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// ToolContent is one content block: text, or an image (base64 Data).
|
||||
type ToolContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
}
|
||||
|
||||
// TextResult is a result the model reads as text.
|
||||
func TextResult(text string) *ToolResult {
|
||||
return &ToolResult{Content: []ToolContent{{Type: "text", Text: text}}}
|
||||
}
|
||||
|
||||
// StructuredResult returns data for the result view, and text for the model
|
||||
// (the data as JSON when text is empty, as MCP recommends).
|
||||
func StructuredResult(data any, text string) *ToolResult {
|
||||
if text == "" {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
text = fmt.Sprint(data)
|
||||
} else {
|
||||
text = string(b)
|
||||
}
|
||||
}
|
||||
return &ToolResult{Content: []ToolContent{{Type: "text", Text: text}}, StructuredContent: data}
|
||||
}
|
||||
|
||||
// ToolError is a failed call the model sees.
|
||||
func ToolError(format string, args ...any) *ToolResult {
|
||||
r := TextResult(fmt.Sprintf(format, args...))
|
||||
r.IsError = true
|
||||
return r
|
||||
}
|
||||
@@ -246,6 +246,37 @@ paths:
|
||||
required: [output]
|
||||
properties: { output: { $ref: "#/components/schemas/OptionsOutput" } }
|
||||
default: { $ref: "#/components/responses/Error" }
|
||||
/v1/mcp/{id}:
|
||||
post:
|
||||
summary: One Model Context Protocol message to the plugin's MCP server
|
||||
description: |
|
||||
For mcpServers contributions declared without a url: the plugin
|
||||
serves the tools itself. The input is one JSON-RPC 2.0 message
|
||||
(initialize, ping, tools/list, tools/call, or a notification); the
|
||||
output is its JSON-RPC response (empty for a notification). Servers
|
||||
are stateless and keep no session. Tool failures the model should see
|
||||
are results with isError, not protocol errors. The manifest lists the
|
||||
server IDs under contributes.mcpServers.
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Envelope"
|
||||
- properties: { input: { $ref: "#/components/schemas/MCPRequest" } }
|
||||
responses:
|
||||
"200":
|
||||
description: The JSON-RPC response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [output]
|
||||
properties: { output: { $ref: "#/components/schemas/MCPResponse" } }
|
||||
default: { $ref: "#/components/responses/Error" }
|
||||
/v1/events:
|
||||
post:
|
||||
summary: Receive an event the plugin subscribed to (permissions.events)
|
||||
@@ -478,6 +509,43 @@ components:
|
||||
value: {}
|
||||
label: { type: string }
|
||||
description: { type: string }
|
||||
MCPRequest:
|
||||
type: object
|
||||
required: [jsonrpc, method]
|
||||
properties:
|
||||
jsonrpc: { const: "2.0" }
|
||||
id: { description: Absent for a notification }
|
||||
method: { type: string }
|
||||
params: { type: object }
|
||||
MCPResponse:
|
||||
type: object
|
||||
required: [jsonrpc]
|
||||
properties:
|
||||
jsonrpc: { const: "2.0" }
|
||||
id: {}
|
||||
result: { type: object, description: "tools/call answers a ToolResult" }
|
||||
error:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: { type: integer }
|
||||
message: { type: string }
|
||||
ToolResult:
|
||||
type: object
|
||||
required: [content]
|
||||
properties:
|
||||
content:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [type]
|
||||
properties:
|
||||
type: { enum: [text, image] }
|
||||
text: { type: string }
|
||||
data: { type: string, description: Base64 image }
|
||||
mimeType: { type: string }
|
||||
structuredContent: { description: Data for the tool's result view (toolViews) }
|
||||
isError: { type: boolean }
|
||||
EventDelivery:
|
||||
type: object
|
||||
required: [id, type, occurredAt, attempt]
|
||||
|
||||
@@ -102,6 +102,22 @@ def projects(call, inp: OptionsInput):
|
||||
return [("p1", "Project one"), ("p2", "Project two")]
|
||||
```
|
||||
|
||||
**Agent tools.** Declare an `mcpServers` contribution without `mcp.url`
|
||||
and add tools to it:
|
||||
|
||||
```python
|
||||
@plugin.tool("tools", "search_issues", "Search issues",
|
||||
{"type": "object", "properties": {"text": {"type": "string"}}}, read_only=True)
|
||||
def search_issues(call, args):
|
||||
rows = [...]
|
||||
return ToolResult.structured({"issues": rows}, text="3 issues ...")
|
||||
```
|
||||
|
||||
Return a `ToolResult`, a `str` (text for the model) or any JSON value
|
||||
(structured content for the result view). Exceptions become a failed result
|
||||
the model sees. See the [Go SDK README](../README.md#agent-tools) for
|
||||
`toolViews`.
|
||||
|
||||
**OAuth.** An `x-oauth` field needs no code: WeKnora runs the flow, and
|
||||
the plugin finds a fresh access token where the field is (see the
|
||||
[Go SDK README](../README.md#dynamic-choices-and-oauth)).
|
||||
|
||||
@@ -42,6 +42,8 @@ from .types import (
|
||||
Resource,
|
||||
SearchInput,
|
||||
SearchResult,
|
||||
ToolContent,
|
||||
ToolResult,
|
||||
UIRequest,
|
||||
UIResponse,
|
||||
WebhookRequest,
|
||||
@@ -78,6 +80,8 @@ __all__ = [
|
||||
"PluginError",
|
||||
"Resource",
|
||||
"SearchInput",
|
||||
"ToolContent",
|
||||
"ToolResult",
|
||||
"SearchResult",
|
||||
"Stream",
|
||||
"StreamClosed",
|
||||
|
||||
@@ -32,6 +32,7 @@ from .types import (
|
||||
SearchInput,
|
||||
SearchResult,
|
||||
EventDelivery,
|
||||
ToolResult,
|
||||
OptionsInput,
|
||||
UIRequest,
|
||||
UIResponse,
|
||||
@@ -169,6 +170,10 @@ UIHandler = Callable[[Call, UIRequest], Any]
|
||||
EventHandler = Callable[[Call, EventDelivery], None]
|
||||
WebhookHandler = Callable[[Call, WebhookRequest], Any]
|
||||
OptionsHandler = Callable[[Call, OptionsInput], Any]
|
||||
ToolHandler = Callable[[Call, dict], Any]
|
||||
|
||||
#: The MCP revision plugins speak.
|
||||
MCP_PROTOCOL_VERSION = "2025-06-18"
|
||||
|
||||
_ROUTE = re.compile(r"^/v1/(websearch|connectors|parsers)/([^/]+)/([a-z-]+)$")
|
||||
|
||||
@@ -189,6 +194,7 @@ class Plugin:
|
||||
self._events: Optional[EventHandler] = None
|
||||
self._webhooks: Dict[str, WebhookHandler] = {}
|
||||
self._options: Dict[str, OptionsHandler] = {}
|
||||
self._mcp: Dict[str, Dict[str, Tuple[dict, ToolHandler]]] = {}
|
||||
#: Seconds serve() waits for calls in flight after SIGTERM.
|
||||
self.shutdown_timeout = 60.0
|
||||
if logger is None:
|
||||
@@ -251,6 +257,37 @@ class Plugin:
|
||||
invalid_config(...) when the form lacks what the list needs."""
|
||||
return self._register(self._options, name, fn)
|
||||
|
||||
def tool(
|
||||
self,
|
||||
server: str,
|
||||
name: str,
|
||||
description: str,
|
||||
input_schema: Optional[dict] = None,
|
||||
*,
|
||||
title: str = "",
|
||||
output_schema: Optional[dict] = None,
|
||||
read_only: bool = False,
|
||||
) -> Callable[[ToolHandler], ToolHandler]:
|
||||
"""Decorator adding a tool to the MCP server the plugin serves as
|
||||
server (contributes.mcpServers[].id, declared without a url):
|
||||
fn(call, args) returns a ToolResult, a str (text for the model) or
|
||||
any JSON value (structured content, shown by the tool's result
|
||||
view). Exceptions become a failed result the model sees. call
|
||||
carries the workspace's configuration."""
|
||||
spec: dict = {"name": name, "description": description, "inputSchema": input_schema or {"type": "object"}}
|
||||
if title:
|
||||
spec["title"] = title
|
||||
if output_schema:
|
||||
spec["outputSchema"] = output_schema
|
||||
if read_only:
|
||||
spec["annotations"] = {"readOnlyHint": True}
|
||||
|
||||
def register(fn: ToolHandler) -> ToolHandler:
|
||||
self._mcp.setdefault(server, {})[name] = (spec, fn)
|
||||
return fn
|
||||
|
||||
return register
|
||||
|
||||
def ui(self, fn: UIHandler) -> UIHandler:
|
||||
"""Registers the handler behind the plugin's pages: fn(call,
|
||||
UIRequest) returns a UIResponse, or any JSON value for a 200."""
|
||||
@@ -284,6 +321,7 @@ class Plugin:
|
||||
("parsers", self._parsers),
|
||||
("webhooks", self._webhooks),
|
||||
("options", self._options),
|
||||
("mcpServers", self._mcp),
|
||||
):
|
||||
if table:
|
||||
contributes[point] = sorted(table)
|
||||
@@ -326,6 +364,14 @@ class Plugin:
|
||||
else:
|
||||
self._unary(h, body, lambda call, raw: {"options": _options_output(fn(call, from_wire(OptionsInput, raw)))})
|
||||
return
|
||||
if method == "POST" and path.startswith("/v1/mcp/"):
|
||||
sid = path[len("/v1/mcp/"):]
|
||||
server = self._mcp.get(sid)
|
||||
if server is None:
|
||||
h._send_error(PluginError(ErrorCode.NOT_FOUND, f"no MCP server {sid!r}"))
|
||||
else:
|
||||
self._unary(h, body, lambda call, raw: self._answer_mcp(call, sid, server, raw or {}))
|
||||
return
|
||||
if method == "POST" and path.startswith("/v1/webhooks/"):
|
||||
hook = self._webhooks.get(path[len("/v1/webhooks/"):])
|
||||
if hook is None:
|
||||
@@ -366,6 +412,57 @@ class Plugin:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _answer_mcp(self, call: Call, sid: str, server: dict, req: dict) -> dict:
|
||||
resp: dict = {"jsonrpc": "2.0"}
|
||||
if "id" in req:
|
||||
resp["id"] = req["id"]
|
||||
method = req.get("method", "")
|
||||
params = req.get("params") or {}
|
||||
if method == "initialize":
|
||||
resp["result"] = {
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": f"{self.id}/{sid}", "version": self.version},
|
||||
}
|
||||
elif method == "ping":
|
||||
resp["result"] = {}
|
||||
# Tools only: clients that list resources or prompts anyway get none.
|
||||
elif method == "resources/list":
|
||||
resp["result"] = {"resources": []}
|
||||
elif method == "resources/templates/list":
|
||||
resp["result"] = {"resourceTemplates": []}
|
||||
elif method == "prompts/list":
|
||||
resp["result"] = {"prompts": []}
|
||||
elif method == "tools/list":
|
||||
resp["result"] = {"tools": [server[n][0] for n in sorted(server)]}
|
||||
elif method == "tools/call":
|
||||
entry = server.get(params.get("name", ""))
|
||||
if entry is None:
|
||||
resp["error"] = {"code": -32602, "message": f"no tool {params.get('name', '')}"}
|
||||
else:
|
||||
result = to_wire(self._call_tool(call, entry[1], params.get("arguments") or {}))
|
||||
result.setdefault("content", [])
|
||||
resp["result"] = result
|
||||
elif "id" in req:
|
||||
resp["error"] = {"code": -32601, "message": f"method {method} is not supported"}
|
||||
return resp
|
||||
|
||||
def _call_tool(self, call: Call, fn: ToolHandler, args: dict) -> ToolResult:
|
||||
try:
|
||||
out = fn(call, args)
|
||||
except PluginError as e:
|
||||
return ToolResult.error(e.message)
|
||||
except Exception as e: # noqa: BLE001 - the model sees the failure
|
||||
self.logger.error("tool failed: %s", "".join(traceback.format_exception(type(e), e, e.__traceback__)))
|
||||
return ToolResult.error(str(e) or type(e).__name__)
|
||||
if isinstance(out, ToolResult):
|
||||
if not out.content:
|
||||
out.content = []
|
||||
return out
|
||||
if isinstance(out, str):
|
||||
return ToolResult.text(out)
|
||||
return ToolResult.structured(out)
|
||||
|
||||
def _unary(self, h: "_Handler", body: bytes, fn: Callable[[Call, Any], Any], empty: bool = False) -> None:
|
||||
try:
|
||||
env = _envelope(body)
|
||||
|
||||
@@ -334,3 +334,43 @@ class Option:
|
||||
value: Any
|
||||
label: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContent:
|
||||
"""One content block of a tool result: text, or an image (base64
|
||||
data)."""
|
||||
|
||||
type: str = "text"
|
||||
text: str = ""
|
||||
data: str = ""
|
||||
mime_type: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""What a tool returns. The model reads content; structured_content is
|
||||
data for the tool's result view (toolViews in plugin.yaml). is_error
|
||||
marks a failure the model should see."""
|
||||
|
||||
content: List[ToolContent] = field(default_factory=list)
|
||||
structured_content: Any = None
|
||||
is_error: bool = False
|
||||
|
||||
@staticmethod
|
||||
def text(text: str) -> "ToolResult":
|
||||
return ToolResult(content=[ToolContent(text=text)])
|
||||
|
||||
@staticmethod
|
||||
def structured(data: Any, text: str = "") -> "ToolResult":
|
||||
"""Data for the result view, and text for the model (the data as
|
||||
JSON when text is empty)."""
|
||||
if not text:
|
||||
import json
|
||||
|
||||
text = json.dumps(to_wire(data), ensure_ascii=False, separators=(",", ":"))
|
||||
return ToolResult(content=[ToolContent(text=text)], structured_content=data)
|
||||
|
||||
@staticmethod
|
||||
def error(message: str) -> "ToolResult":
|
||||
return ToolResult(content=[ToolContent(text=message)], is_error=True)
|
||||
|
||||
@@ -85,6 +85,15 @@ def on_event(call, ev):
|
||||
seen_events.append((ev.id, ev.type, ev.attempt, call.tenant_id, (ev.data or {}).get("knowledgeId")))
|
||||
|
||||
|
||||
@plugin.tool("issues", "search", "Search issues", {"type": "object", "properties": {"q": {"type": "string"}}}, read_only=True)
|
||||
def search_issues(call, args):
|
||||
if args.get("q") == "boom":
|
||||
raise PluginError(ErrorCode.UNAUTHORIZED, "token expired")
|
||||
if args.get("q") == "text":
|
||||
return "plain"
|
||||
return {"q": args.get("q"), "tenant": call.tenant_id}
|
||||
|
||||
|
||||
@plugin.options("projects")
|
||||
def projects(call, inp):
|
||||
if not call.tenant.get("token"):
|
||||
|
||||
@@ -82,6 +82,7 @@ class PluginTest(unittest.TestCase):
|
||||
"parsers": ["upper"],
|
||||
"webhooks": ["inbox"],
|
||||
"options": ["projects"],
|
||||
"mcpServers": ["issues"],
|
||||
"ui": ["request"],
|
||||
"events": ["handler"],
|
||||
},
|
||||
@@ -141,6 +142,30 @@ class PluginTest(unittest.TestCase):
|
||||
status, body = self.c.call("/v1/options/projects", inp, {"tenant": {"token": "t"}})
|
||||
self.assertEqual(body["output"]["options"], [{"value": "p1", "label": "Project x"}, {"value": "p2", "label": "Other"}])
|
||||
|
||||
def test_tools(self):
|
||||
def send(msg, config=None):
|
||||
status, body = self.c.call("/v1/mcp/issues", dict(msg, jsonrpc="2.0"), config)
|
||||
self.assertEqual(status, 200)
|
||||
return body["output"]
|
||||
|
||||
init = send({"id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})
|
||||
self.assertEqual((init["id"], init["result"]["capabilities"]), (1, {"tools": {}}))
|
||||
tools = send({"id": 2, "method": "tools/list"})["result"]["tools"]
|
||||
self.assertEqual([t["name"] for t in tools], ["search"])
|
||||
self.assertEqual(tools[0]["annotations"], {"readOnlyHint": True})
|
||||
out = send({"id": 3, "method": "tools/call", "params": {"name": "search", "arguments": {"q": "login"}}})
|
||||
self.assertEqual(out["result"]["structuredContent"], {"q": "login", "tenant": 7})
|
||||
self.assertEqual(json.loads(out["result"]["content"][0]["text"]), {"q": "login", "tenant": 7})
|
||||
out = send({"id": 4, "method": "tools/call", "params": {"name": "search", "arguments": {"q": "text"}}})
|
||||
self.assertEqual(out["result"], {"content": [{"type": "text", "text": "plain"}]})
|
||||
out = send({"id": 5, "method": "tools/call", "params": {"name": "search", "arguments": {"q": "boom"}}})
|
||||
self.assertEqual(out["result"], {"content": [{"type": "text", "text": "token expired"}], "isError": True})
|
||||
self.assertEqual(send({"id": 6, "method": "tools/call", "params": {"name": "nope"}})["error"]["code"], -32602)
|
||||
self.assertEqual(send({"id": 7, "method": "resources/list"})["result"], {"resources": []})
|
||||
self.assertEqual(send({"id": 8, "method": "completion/complete"})["error"]["code"], -32601)
|
||||
self.assertNotIn("error", send({"method": "notifications/initialized"}))
|
||||
self.assertEqual(self.c.call("/v1/mcp/nope", {"jsonrpc": "2.0", "method": "ping"})[0], 404)
|
||||
|
||||
def test_webhooks(self):
|
||||
body = base64.b64encode(b'{"a": 1}').decode()
|
||||
req = {"method": "POST", "path": "/issue", "headers": {"X-Signature": "ok"}, "body": body}
|
||||
|
||||
@@ -129,6 +129,14 @@ Helm 设置 `pluginHost.enabled=true` 即可。使用本地存储(`STORAGE_TYP
|
||||
- 插件自行用空间配置里的密钥校验调用方。
|
||||
- 设置 `APP_EXTERNAL_URL` 后,插件还能拿到完整地址,自动向第三方注册。
|
||||
|
||||
## 插件工具
|
||||
|
||||
插件可以给 Agent 提供工具。工具统一按 MCP 服务接入:
|
||||
|
||||
- 启用了插件的空间,会在 MCP 服务列表中看到插件提供的服务(只读),其中的工具与其他 MCP 工具一样,可在 Agent 中选用,并沿用相同的工具审批设置。
|
||||
- 代码插件可以自己提供工具,无需单独部署 MCP 服务。WeKnora 调用工具时带上当前空间的插件配置,所以工具使用的是空间管理员在「设置 → 插件」中填写的账号。
|
||||
- 插件可以为工具结果声明展示方式(表格、卡片、键值、Markdown、插件页面),对话中按此展示结构化结果;模型读取的仍是文本结果。
|
||||
|
||||
## 动态选项与账号连接
|
||||
|
||||
插件的配置表单可以在填写时向插件取数据:
|
||||
@@ -157,6 +165,6 @@ Helm 设置 `pluginHost.enabled=true` 即可。使用本地存储(`STORAGE_TYP
|
||||
- `notebooks`:Jupyter 笔记本解析器,Python;
|
||||
- `links`:带三种页面的团队链接插件,Python;
|
||||
- `activity`:订阅事件、接收 Webhook 的空间动态插件,Python;
|
||||
- `jira`:Jira Cloud 问题同步,支持 OAuth 授权或 API 令牌,带动态选项、增量同步与问题分诊技能,Go。
|
||||
- `jira`:Jira Cloud 问题同步与 Agent 工具(搜索、读取问题),支持 OAuth 授权或 API 令牌,带动态选项、增量同步与问题分诊技能,Go。
|
||||
|
||||
同一个插件既可以打包成 `host` 插件由 WeKnora 运行,也可以作为 `remote` 服务独立部署,代码不用改。
|
||||
|
||||
Reference in New Issue
Block a user