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:
lyingbug
2026-09-26 14:48:37 +08:00
committed by GitHub
parent 4f5d475c3e
commit a829d7cc9c
56 changed files with 2334 additions and 70 deletions
+7 -2
View File
@@ -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
+17
View File
@@ -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 {
+87 -2
View File
@@ -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)
}
}
+48 -28
View File
@@ -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
}
+4
View File
@@ -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 {
+22 -2
View File
@@ -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 问题分诊 }
+73
View File
@@ -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 令牌 中创建 }
+172
View File
@@ -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 ""
}
+1 -1
View File
@@ -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), {})
})
+157
View File
@@ -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
}
+4
View File
@@ -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",
+4
View File
@@ -2859,6 +2859,9 @@ export default {
rotateFailed: 'シークレットのローテーションに失敗しました'
}
},
pluginToolView: {
empty: '結果がありません'
},
pluginPages: {
notResponding: 'プラグインページが応答しません。読み込みに失敗した可能性があります。',
requestFailed: 'プラグインへのリクエストに失敗しました',
@@ -5555,6 +5558,7 @@ export default {
}
},
mcpSettings: {
transportPlugin: 'プラグイン内蔵',
fromPlugin: 'プラグイン',
pluginNotConfigured: 'プラグインが未設定です。プラグインセンターで設定してから使用してください',
addUsageInstructions: "使用方法を追加",
+4
View File
@@ -2663,6 +2663,7 @@ export default {
}
},
mcpSettings: {
transportPlugin: '플러그인 내장',
fromPlugin: '플러그인',
pluginNotConfigured: '플러그인이 아직 설정되지 않았습니다. 플러그인 센터에서 설정한 뒤 사용하세요',
addUsageInstructions: "사용 안내 추가",
@@ -5377,6 +5378,9 @@ export default {
rotateFailed: '비밀 키를 교체하지 못했습니다'
}
},
pluginToolView: {
empty: '결과가 없습니다'
},
pluginPages: {
notResponding: '플러그인 페이지가 응답하지 않습니다. 로드에 실패했을 수 있습니다.',
requestFailed: '플러그인 요청에 실패했습니다',
+4
View File
@@ -2663,6 +2663,7 @@ export default {
}
},
mcpSettings: {
transportPlugin: 'Встроено в плагин',
fromPlugin: 'Плагин',
pluginNotConfigured: 'Плагин ещё не настроен; заполните его настройки в разделе «Плагины»',
addUsageInstructions: "Добавить инструкции",
@@ -5377,6 +5378,9 @@ export default {
rotateFailed: 'Не удалось сменить секрет'
}
},
pluginToolView: {
empty: 'Нет результатов'
},
pluginPages: {
notResponding: 'Страница плагина не отвечает; возможно, она не загрузилась.',
requestFailed: 'Запрос к плагину не выполнен',
+4
View File
@@ -2665,6 +2665,7 @@ export default {
}
},
mcpSettings: {
transportPlugin: '插件内置',
fromPlugin: '插件',
pluginNotConfigured: '插件尚未配置,请在插件中心填写配置后使用',
addUsageInstructions: "添加使用说明",
@@ -5379,6 +5380,9 @@ export default {
rotateFailed: '轮换密钥失败'
}
},
pluginToolView: {
empty: '没有结果'
},
pluginPages: {
notResponding: '插件页面没有响应,可能加载失败。',
requestFailed: '插件请求失败',
+2 -1
View File
@@ -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
}
+1
View File
@@ -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))
+38
View File
@@ -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{})
}
+1
View File
@@ -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
View File
@@ -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
}
+44
View File
@@ -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)
}
+2
View File
@@ -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
+31 -10
View File
@@ -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
+92
View File
@@ -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")
}
}
+10 -4
View File
@@ -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
}
}
+9
View File
@@ -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
+30
View File
@@ -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)
}
}
+19 -5
View File
@@ -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://") {
+52
View File
@@ -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)
}
}
}
+151
View File
@@ -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)
}
}
}
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+35
View File
@@ -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
+136
View File
@@ -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, &params); 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")
}
}
+2 -1
View File
@@ -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)
+4
View File
@@ -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))
})
+75
View File
@@ -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)
}
}
+114
View File
@@ -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
}
+68
View File
@@ -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]
+16
View File
@@ -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)
+9
View File
@@ -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"):
+25
View File
@@ -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}
+9 -1
View File
@@ -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` 服务独立部署,代码不用改。