Update Agent Documentation with MCP Directory Structure and Model Integration
- Added a new section in the MCP documentation detailing the directory structure for assistants, enhancing clarity on how to define namespaced MCP servers. - Included examples of using assistant models within MCP tools, providing guidance on referencing models in tool configurations. - Updated the README to include a link to the new Models documentation, improving navigation and resource accessibility for users.
This commit is contained in:
parent
692caec864
commit
b7fc91a312
3 changed files with 425 additions and 15 deletions
|
|
@ -181,6 +181,7 @@ ctx.Send({
|
||||||
- [Hooks](docs/hooks.md) - Create/Next hooks and agent lifecycle
|
- [Hooks](docs/hooks.md) - Create/Next hooks and agent lifecycle
|
||||||
- [Context API](docs/context-api.md) - Messaging, memory, trace, MCP
|
- [Context API](docs/context-api.md) - Messaging, memory, trace, MCP
|
||||||
- [MCP Integration](docs/mcp.md) - Tool servers and resources
|
- [MCP Integration](docs/mcp.md) - Tool servers and resources
|
||||||
|
- [Models](docs/models.md) - Assistant-scoped data models
|
||||||
- [Search](docs/search.md) - Web, knowledge base, and database search
|
- [Search](docs/search.md) - Web, knowledge base, and database search
|
||||||
- [Pages](docs/pages.md) - Web UI for agents (SUI framework)
|
- [Pages](docs/pages.md) - Web UI for agents (SUI framework)
|
||||||
- [Internationalization](docs/i18n.md) - Multi-language support
|
- [Internationalization](docs/i18n.md) - Multi-language support
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,26 @@
|
||||||
|
|
||||||
Model Context Protocol (MCP) enables tool integration with external services.
|
Model Context Protocol (MCP) enables tool integration with external services.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
Assistants can define their own namespaced MCP servers in the `mcps/` directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
assistants/
|
||||||
|
└── my-assistant/
|
||||||
|
├── package.yao
|
||||||
|
└── mcps/
|
||||||
|
├── tools.mcp.yao # → agents.my-assistant.tools
|
||||||
|
├── calculator.mcp.yao # → agents.my-assistant.calculator
|
||||||
|
└── mapping/
|
||||||
|
└── tools/
|
||||||
|
└── schemes/
|
||||||
|
├── search.in.yao
|
||||||
|
└── search.out.yao
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP servers are automatically loaded with `agents.<assistant-id>.` prefix.
|
||||||
|
|
||||||
## Defining MCP Servers
|
## Defining MCP Servers
|
||||||
|
|
||||||
Create `mcps/tools.mcp.yao` in the assistant directory:
|
Create `mcps/tools.mcp.yao` in the assistant directory:
|
||||||
|
|
@ -75,9 +95,7 @@ Map Yao Processes directly to MCP tools:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"servers": [
|
"servers": [{ "server_id": "tools", "tools": ["search", "calculate"] }]
|
||||||
{ "server_id": "tools", "tools": ["search", "calculate"] }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -106,8 +124,8 @@ function Create(ctx: agent.Context, messages: agent.Message[]): agent.Create {
|
||||||
messages,
|
messages,
|
||||||
mcp_servers: [
|
mcp_servers: [
|
||||||
{ server_id: "tools", tools: ["search"] },
|
{ server_id: "tools", tools: ["search"] },
|
||||||
{ server_id: "data", resources: ["data://reports"] }
|
{ server_id: "data", resources: ["data://reports"] },
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -126,7 +144,7 @@ const tools = ctx.mcp.ListTools("server-id");
|
||||||
```typescript
|
```typescript
|
||||||
const result = ctx.mcp.CallTool("server-id", "search", {
|
const result = ctx.mcp.CallTool("server-id", "search", {
|
||||||
query: "example",
|
query: "example",
|
||||||
limit: 10
|
limit: 10,
|
||||||
});
|
});
|
||||||
// { content: [{ type: "text", text: "..." }] }
|
// { content: [{ type: "text", text: "..." }] }
|
||||||
```
|
```
|
||||||
|
|
@ -137,13 +155,13 @@ const result = ctx.mcp.CallTool("server-id", "search", {
|
||||||
// Sequential
|
// Sequential
|
||||||
const results = ctx.mcp.CallTools("server-id", [
|
const results = ctx.mcp.CallTools("server-id", [
|
||||||
{ name: "step1", arguments: { input: "a" } },
|
{ name: "step1", arguments: { input: "a" } },
|
||||||
{ name: "step2", arguments: { input: "b" } }
|
{ name: "step2", arguments: { input: "b" } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Parallel
|
// Parallel
|
||||||
const results = ctx.mcp.CallToolsParallel("server-id", [
|
const results = ctx.mcp.CallToolsParallel("server-id", [
|
||||||
{ name: "api1", arguments: {} },
|
{ name: "api1", arguments: {} },
|
||||||
{ name: "api2", arguments: {} }
|
{ name: "api2", arguments: {} },
|
||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -189,9 +207,60 @@ mcps/
|
||||||
```
|
```
|
||||||
|
|
||||||
The `x-process-args` maps MCP arguments to Yao Process parameters:
|
The `x-process-args` maps MCP arguments to Yao Process parameters:
|
||||||
|
|
||||||
- `":arguments"` - Pass entire arguments object
|
- `":arguments"` - Pass entire arguments object
|
||||||
- `"$args.field"` - Extract specific field
|
- `"$args.field"` - Extract specific field
|
||||||
|
|
||||||
|
### Schema with Nested Objects
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Extract structured data from input",
|
||||||
|
"properties": {
|
||||||
|
"intent": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["query", "create", "update"],
|
||||||
|
"description": "Operation intent"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"value": { "type": "number" }
|
||||||
|
},
|
||||||
|
"required": ["name", "value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["intent"],
|
||||||
|
"x-process-args": [":arguments"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Assistant Models in MCP
|
||||||
|
|
||||||
|
MCP tools can reference assistant's own models:
|
||||||
|
|
||||||
|
**mcps/data.mcp.yao**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "Data Tools",
|
||||||
|
"transport": "process",
|
||||||
|
"tools": {
|
||||||
|
"list_orders": "models.agents.my-assistant.order.Paginate",
|
||||||
|
"get_order": "models.agents.my-assistant.order.Find",
|
||||||
|
"create_order": "models.agents.my-assistant.order.Create",
|
||||||
|
"custom_query": "agents.my-assistant.orders.Query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Models](models.md) for defining assistant models.
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -237,9 +306,7 @@ function Next(ctx: agent.Context, payload: agent.Payload): agent.Next {
|
||||||
"name": "Math Assistant",
|
"name": "Math Assistant",
|
||||||
"connector": "gpt-4o",
|
"connector": "gpt-4o",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"servers": [
|
"servers": [{ "server_id": "calculator", "tools": ["add", "multiply"] }]
|
||||||
{ "server_id": "calculator", "tools": ["add", "multiply"] }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -254,7 +321,7 @@ function Create(ctx: agent.Context, messages: agent.Message[]): agent.Create {
|
||||||
// Enable calculator
|
// Enable calculator
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
mcp_servers: [{ server_id: "calculator" }]
|
mcp_servers: [{ server_id: "calculator" }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { messages };
|
return { messages };
|
||||||
|
|
@ -264,13 +331,13 @@ function Next(ctx: agent.Context, payload: agent.Payload): agent.Next {
|
||||||
const { tools } = payload;
|
const { tools } = payload;
|
||||||
|
|
||||||
if (tools?.length > 0) {
|
if (tools?.length > 0) {
|
||||||
const calcResult = tools.find(t => t.server === "calculator");
|
const calcResult = tools.find((t) => t.server === "calculator");
|
||||||
if (calcResult?.result) {
|
if (calcResult?.result) {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
answer: calcResult.result,
|
answer: calcResult.result,
|
||||||
expression: calcResult.arguments
|
expression: calcResult.arguments,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
342
agent/docs/models.md
Normal file
342
agent/docs/models.md
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
# Assistant Models
|
||||||
|
|
||||||
|
Assistants can define their own namespaced data models in the `models/` directory. These models are automatically loaded with the `agents.<assistant-id>.` prefix and use isolated database tables.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
assistants/
|
||||||
|
└── my-assistant/
|
||||||
|
├── package.yao
|
||||||
|
└── models/
|
||||||
|
├── order.mod.yao # → agents.my-assistant.order
|
||||||
|
├── item.mod.yao # → agents.my-assistant.item
|
||||||
|
└── nested/
|
||||||
|
└── log.mod.yao # → agents.my-assistant.nested.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model Definition
|
||||||
|
|
||||||
|
Standard Yao model definition with automatic table prefixing.
|
||||||
|
|
||||||
|
**models/order.mod.yao**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Order",
|
||||||
|
"label": "Order Record",
|
||||||
|
"description": "Customer orders",
|
||||||
|
"table": {
|
||||||
|
"name": "order",
|
||||||
|
"comment": "Order records"
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "ID",
|
||||||
|
"label": "ID",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "order_no",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Order Number",
|
||||||
|
"length": 100,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customer_id",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Customer ID",
|
||||||
|
"length": 255,
|
||||||
|
"nullable": false,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_amount",
|
||||||
|
"type": "decimal",
|
||||||
|
"label": "Total Amount",
|
||||||
|
"precision": 15,
|
||||||
|
"scale": 2,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"type": "enum",
|
||||||
|
"label": "Status",
|
||||||
|
"option": ["pending", "confirmed", "shipped", "completed", "cancelled"],
|
||||||
|
"default": "pending",
|
||||||
|
"nullable": false,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Metadata",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relations": {
|
||||||
|
"items": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"model": "item",
|
||||||
|
"key": "order_id",
|
||||||
|
"foreign": "id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"name": "idx_customer_status",
|
||||||
|
"columns": ["customer_id", "status"],
|
||||||
|
"type": "index"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"option": {
|
||||||
|
"timestamps": true,
|
||||||
|
"soft_deletes": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Table Naming
|
||||||
|
|
||||||
|
Tables are automatically prefixed with `agents_<assistant-id>_`:
|
||||||
|
|
||||||
|
| Assistant ID | Model File | Model ID | Table Name |
|
||||||
|
| ------------ | ---------------------- | ------------------------ | ------------------------ |
|
||||||
|
| `expense` | `models/order.mod.yao` | `agents.expense.order` | `agents_expense_order` |
|
||||||
|
| `tests.demo` | `models/user.mod.yao` | `agents.tests.demo.user` | `agents_tests_demo_user` |
|
||||||
|
|
||||||
|
## Using Models
|
||||||
|
|
||||||
|
### In Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Process } from "@yao/runtime";
|
||||||
|
|
||||||
|
function Create(ctx: agent.Context, messages: agent.Message[]): agent.Create {
|
||||||
|
// Query assistant's own model
|
||||||
|
const orders = Process("models.agents.my-assistant.order.Paginate", {
|
||||||
|
wheres: [{ column: "status", value: "pending" }],
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { messages };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### In MCP Tools
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transport": "process",
|
||||||
|
"tools": {
|
||||||
|
"list_orders": "models.agents.my-assistant.order.Paginate",
|
||||||
|
"get_order": "models.agents.my-assistant.order.Find",
|
||||||
|
"create_order": "models.agents.my-assistant.order.Create",
|
||||||
|
"update_order": "models.agents.my-assistant.order.Update"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Scripts
|
||||||
|
|
||||||
|
**src/orders.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Process } from "@yao/runtime";
|
||||||
|
|
||||||
|
export function ListPending(): any[] {
|
||||||
|
return Process("models.agents.my-assistant.order.Get", {
|
||||||
|
wheres: [{ column: "status", value: "pending" }],
|
||||||
|
orders: [{ column: "created_at", option: "desc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateOrder(data: any): any {
|
||||||
|
return Process("models.agents.my-assistant.order.Create", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateStatus(id: number, status: string): any {
|
||||||
|
return Process("models.agents.my-assistant.order.Update", id, { status });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Column Types
|
||||||
|
|
||||||
|
| Type | Description | Options |
|
||||||
|
| ------------ | -------------------------- | ----------------------- |
|
||||||
|
| `ID` | Auto-increment primary key | `primary: true` |
|
||||||
|
| `string` | VARCHAR | `length` (default: 255) |
|
||||||
|
| `text` | TEXT | - |
|
||||||
|
| `integer` | INT | - |
|
||||||
|
| `bigInteger` | BIGINT | - |
|
||||||
|
| `float` | FLOAT | `precision`, `scale` |
|
||||||
|
| `decimal` | DECIMAL | `precision`, `scale` |
|
||||||
|
| `boolean` | BOOLEAN | - |
|
||||||
|
| `date` | DATE | - |
|
||||||
|
| `datetime` | DATETIME | - |
|
||||||
|
| `timestamp` | TIMESTAMP | - |
|
||||||
|
| `json` | JSON/JSONB | - |
|
||||||
|
| `enum` | ENUM | `option: [...]` |
|
||||||
|
|
||||||
|
## Column Options
|
||||||
|
|
||||||
|
| Option | Type | Description |
|
||||||
|
| ----------- | --------- | ----------------- |
|
||||||
|
| `nullable` | `boolean` | Allow NULL values |
|
||||||
|
| `default` | `any` | Default value |
|
||||||
|
| `unique` | `boolean` | Unique constraint |
|
||||||
|
| `index` | `boolean` | Create index |
|
||||||
|
| `primary` | `boolean` | Primary key |
|
||||||
|
| `length` | `integer` | String length |
|
||||||
|
| `precision` | `integer` | Decimal precision |
|
||||||
|
| `scale` | `integer` | Decimal scale |
|
||||||
|
| `comment` | `string` | Column comment |
|
||||||
|
|
||||||
|
## Relations
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"relations": {
|
||||||
|
"items": {
|
||||||
|
"type": "hasMany",
|
||||||
|
"model": "item",
|
||||||
|
"key": "order_id",
|
||||||
|
"foreign": "id"
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"type": "hasOne",
|
||||||
|
"model": "customer",
|
||||||
|
"key": "id",
|
||||||
|
"foreign": "customer_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
| ---------------- | ----------------------------- |
|
||||||
|
| `hasOne` | One-to-one relationship |
|
||||||
|
| `hasMany` | One-to-many relationship |
|
||||||
|
| `hasOneThrough` | Has one through intermediate |
|
||||||
|
| `hasManyThrough` | Has many through intermediate |
|
||||||
|
|
||||||
|
## Model Options
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"option": {
|
||||||
|
"timestamps": true,
|
||||||
|
"soft_deletes": true,
|
||||||
|
"permission": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
| -------------- | -------------------------------------- |
|
||||||
|
| `timestamps` | Add `created_at`, `updated_at` columns |
|
||||||
|
| `soft_deletes` | Add `deleted_at` for soft delete |
|
||||||
|
| `permission` | Enable permission checks |
|
||||||
|
|
||||||
|
## Process Reference
|
||||||
|
|
||||||
|
Common model processes:
|
||||||
|
|
||||||
|
| Process | Description | Arguments |
|
||||||
|
| ------------- | ---------------- | --------------------------- |
|
||||||
|
| `Find` | Get by ID | `id`, `query?` |
|
||||||
|
| `Get` | Get records | `query` |
|
||||||
|
| `Paginate` | Paginated list | `query`, `page`, `pagesize` |
|
||||||
|
| `Create` | Create record | `data` |
|
||||||
|
| `Update` | Update record | `id`, `data` |
|
||||||
|
| `Save` | Create or update | `data` |
|
||||||
|
| `Delete` | Delete record | `id` |
|
||||||
|
| `Destroy` | Hard delete | `id` |
|
||||||
|
| `Insert` | Batch insert | `columns`, `rows` |
|
||||||
|
| `UpdateWhere` | Batch update | `query`, `data` |
|
||||||
|
| `DeleteWhere` | Batch delete | `query` |
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Models are automatically migrated when Yao starts. The migration:
|
||||||
|
|
||||||
|
1. Creates tables if not exist
|
||||||
|
2. Adds new columns
|
||||||
|
3. Creates indexes
|
||||||
|
4. Does NOT drop columns (safe migration)
|
||||||
|
|
||||||
|
To force schema sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yao migrate --reset # Warning: drops and recreates tables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Complete Assistant with Models
|
||||||
|
|
||||||
|
**assistants/inventory/package.yao**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Inventory Assistant",
|
||||||
|
"connector": "gpt-4o",
|
||||||
|
"mcp": {
|
||||||
|
"servers": [{ "server_id": "inventory" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**assistants/inventory/models/product.mod.yao**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Product",
|
||||||
|
"table": { "name": "product" },
|
||||||
|
"columns": [
|
||||||
|
{ "name": "id", "type": "ID", "primary": true },
|
||||||
|
{ "name": "sku", "type": "string", "length": 50, "unique": true },
|
||||||
|
{ "name": "name", "type": "string", "length": 200 },
|
||||||
|
{ "name": "quantity", "type": "integer", "default": 0 },
|
||||||
|
{ "name": "price", "type": "decimal", "precision": 10, "scale": 2 }
|
||||||
|
],
|
||||||
|
"option": { "timestamps": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**assistants/inventory/mcps/inventory.mcp.yao**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "Inventory",
|
||||||
|
"transport": "process",
|
||||||
|
"tools": {
|
||||||
|
"list_products": "models.agents.inventory.product.Paginate",
|
||||||
|
"get_product": "models.agents.inventory.product.Find",
|
||||||
|
"update_stock": "agents.inventory.stock.Update"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**assistants/inventory/src/stock.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Process } from "@yao/runtime";
|
||||||
|
|
||||||
|
export function Update(args: { sku: string; quantity: number }): any {
|
||||||
|
const product = Process("models.agents.inventory.product.Get", {
|
||||||
|
wheres: [{ column: "sku", value: args.sku }],
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product || product.length === 0) {
|
||||||
|
throw new Error(`Product not found: ${args.sku}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Process("models.agents.inventory.product.Update", product[0].id, {
|
||||||
|
quantity: args.quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
Loading…
Add table
Reference in a new issue