feat: [mcp-5] add Application.Category and Application.Type fields for agent applications (MCP, A2A) (#5102)

This commit is contained in:
Yang Luo
2026-02-15 21:28:00 +08:00
parent 9d1e5c10d0
commit 3cb9df3723
7 changed files with 308 additions and 1 deletions

View File

@@ -67,12 +67,21 @@ type JwtItem struct {
Type string `json:"type"`
}
type ScopeItem struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
}
type Application struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Category string `xorm:"varchar(20)" json:"category"`
Type string `xorm:"varchar(20)" json:"type"`
Scopes []*ScopeItem `xorm:"mediumtext" json:"scopes"`
Logo string `xorm:"varchar(200)" json:"logo"`
Title string `xorm:"varchar(100)" json:"title"`
Favicon string `xorm:"varchar(200)" json:"favicon"`

View File

@@ -198,6 +198,9 @@ func initBuiltInApplication() {
Name: "app-built-in",
CreatedTime: util.GetCurrentTime(),
DisplayName: "Casdoor",
Category: "Default",
Type: "All",
Scopes: []*ScopeItem{},
Logo: fmt.Sprintf("%s/img/casdoor-logo_1185x256.png", conf.GetConfigString("staticBaseUrl")),
HomepageUrl: "https://casdoor.org",
Organization: "built-in",

View File

@@ -133,6 +133,9 @@ func RegisterDynamicClient(req *DynamicClientRegistrationRequest, organization s
Organization: organization,
CreatedTime: createdTime,
DisplayName: req.ClientName,
Category: "Agent",
Type: "MCP",
Scopes: []*ScopeItem{},
Logo: req.LogoUri,
HomepageUrl: req.ClientUri,
ClientId: clientId,

View File

@@ -125,6 +125,23 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
jwksUri = fmt.Sprintf("%s/.well-known/jwks", originBackend)
}
// Default OIDC scopes
scopes := []string{"openid", "email", "profile", "address", "phone", "offline_access"}
// Merge application-specific custom scopes if application is provided
if applicationName != "" {
applicationId := util.GetId("admin", applicationName)
application, err := GetApplication(applicationId)
if err == nil && application != nil && len(application.Scopes) > 0 {
for _, scope := range application.Scopes {
// Add custom scope names to the scopes list
if scope.Name != "" {
scopes = append(scopes, scope.Name)
}
}
}
}
// Examples:
// https://login.okta.com/.well-known/openid-configuration
// https://auth0.auth0.com/.well-known/openid-configuration
@@ -144,7 +161,7 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
GrantTypesSupported: []string{"authorization_code", "implicit", "password", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
SubjectTypesSupported: []string{"public"},
IdTokenSigningAlgValuesSupported: []string{"RS256", "RS512", "ES256", "ES384", "ES512"},
ScopesSupported: []string{"openid", "email", "profile", "address", "phone", "offline_access"},
ScopesSupported: scopes,
CodeChallengeMethodsSupported: []string{"S256"},
ClaimsSupported: []string{"iss", "ver", "sub", "aud", "iat", "exp", "id", "type", "displayName", "avatar", "permanentAvatar", "email", "phone", "location", "affiliation", "title", "homepage", "bio", "tag", "region", "language", "score", "ranking", "isOnline", "isAdmin", "isForbidden", "signupApplication", "ldap"},
RequestParameterSupported: true,

View File

@@ -48,6 +48,7 @@ import ProviderTable from "./table/ProviderTable";
import SigninMethodTable from "./table/SigninMethodTable";
import SignupTable from "./table/SignupTable";
import SamlAttributeTable from "./table/SamlAttributeTable";
import ScopeTable from "./table/ScopeTable";
import PromptPage from "./auth/PromptPage";
import copy from "copy-to-clipboard";
import ThemeEditor from "./common/theme/ThemeEditor";
@@ -307,6 +308,61 @@ class ApplicationEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Category"), i18next.t("general:Category - Tooltip"))} :
</Col>
<Col span={21} >
<Select
virtual={false}
style={{width: "100%"}}
value={this.state.application.category}
onChange={(value) => {
this.updateApplicationField("category", value);
if (value === "Agent") {
this.updateApplicationField("type", "MCP");
} else {
this.updateApplicationField("type", "All");
}
}}
>
<Option value="Default">Default</Option>
<Option value="Agent">Agent</Option>
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Type"), i18next.t("general:Type - Tooltip"))} :
</Col>
<Col span={21} >
<Select
virtual={false}
style={{width: "100%"}}
value={this.state.application.type}
onChange={(value) => {
this.updateApplicationField("type", value);
}}
>
{
(this.state.application.category === "Agent") ? (
<>
<Option value="MCP">MCP</Option>
<Option value="A2A">A2A</Option>
</>
) : (
<>
<Option value="All">All</Option>
<Option value="OIDC">OIDC</Option>
<Option value="OAuth">OAuth</Option>
<Option value="SAML">SAML</Option>
<Option value="CAS">CAS</Option>
</>
)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Is shared"), i18next.t("general:Is shared - Tooltip"))} :
@@ -516,6 +572,22 @@ class ApplicationEditPage extends React.Component {
</Select>
</Col>
</Row>
{
(this.state.application.category === "Agent") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Scopes"), i18next.t("general:Scopes - Tooltip"))} :
</Col>
<Col span={21} >
<ScopeTable
title={i18next.t("general:Scopes")}
table={this.state.application.scopes}
onUpdateTable={(value) => {this.updateApplicationField("scopes", value);}}
/>
</Col>
</Row>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token format"), i18next.t("application:Token format - Tooltip"))} :

View File

@@ -38,6 +38,9 @@ class ApplicationListPage extends BaseListPage {
organization: organizationName,
createdTime: moment().format(),
displayName: `New Application - ${randomName}`,
category: "Default",
type: "All",
scopes: [],
logo: `${Setting.StaticBaseUrl}/img/casdoor-logo_1185x256.png`,
enablePassword: true,
enableSignUp: true,
@@ -179,6 +182,40 @@ class ApplicationListPage extends BaseListPage {
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("general:Category"),
dataIndex: "category",
key: "category",
width: "120px",
sorter: true,
...this.getColumnSearchProps("category"),
render: (text, record, index) => {
const category = text;
const tagColor = category === "Agent" ? "green" : "blue";
return (
<span style={{
padding: "4px 8px",
borderRadius: "4px",
backgroundColor: tagColor,
color: "white",
fontWeight: "500",
}}>
{category}
</span>
);
},
},
{
title: i18next.t("general:Type"),
dataIndex: "type",
key: "type",
width: "100px",
sorter: true,
...this.getColumnSearchProps("type"),
render: (text, record, index) => {
return text;
},
},
{
title: "Logo",
dataIndex: "logo",

166
web/src/table/ScopeTable.js Normal file
View File

@@ -0,0 +1,166 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
import {Button, Input, Table, Tooltip} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
class ScopeTable extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
updateTable(table) {
this.props.onUpdateTable(table);
}
updateField(table, index, key, value) {
table[index][key] = value;
this.updateTable(table);
}
addRow(table) {
const row = {name: "", displayName: "", description: ""};
if (table === undefined) {
table = [];
}
table = Setting.addRow(table, row);
this.updateTable(table);
}
deleteRow(table, i) {
table = Setting.deleteRow(table, i);
this.updateTable(table);
}
upRow(table, i) {
table = Setting.swapRow(table, i - 1, i);
this.updateTable(table);
}
downRow(table, i) {
table = Setting.swapRow(table, i, i + 1);
this.updateTable(table);
}
renderTable(table) {
if (table === null) {
return null;
}
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "25%",
render: (text, record, index) => {
return (
<Input
value={text}
placeholder="e.g., files:read"
onChange={e => {
this.updateField(table, index, "name", e.target.value);
}}
/>
);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "25%",
render: (text, record, index) => {
return (
<Input
value={text}
placeholder="e.g., Read Files"
onChange={e => {
this.updateField(table, index, "displayName", e.target.value);
}}
/>
);
},
},
{
title: i18next.t("general:Description"),
dataIndex: "description",
key: "description",
width: "40%",
render: (text, record, index) => {
return (
<Input
value={text}
placeholder="e.g., Allow reading your files and documents"
onChange={e => {
this.updateField(table, index, "description", e.target.value);
}}
/>
);
},
},
{
title: i18next.t("general:Action"),
key: "action",
width: "10%",
render: (text, record, index) => {
return (
<div>
<Tooltip placement="bottomLeft" title={i18next.t("general:Up")}>
<Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Down")}>
<Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Delete")}>
<Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
</Tooltip>
</div>
);
},
},
];
return (
<div>
<Table scroll={{x: "max-content"}} rowKey={(record, index) => index} columns={columns} dataSource={table} size="middle" bordered pagination={false}
title={() => (
<div>
{this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
<Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
</div>
)}
/>
</div>
);
}
render() {
return (
<div>
{
this.renderTable(this.props.table)
}
</div>
);
}
}
export default ScopeTable;