Remove mcp-gateway/erpnext-mcp/src/tools/core.ts
This commit is contained in:
parent
ffd33f9507
commit
7ff1aa8557
1 changed files with 0 additions and 454 deletions
|
|
@ -1,454 +0,0 @@
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { ERPNextClient, truncateResponse, formatDocumentMarkdown, formatListMarkdown } from "../services/erpnext-client.js";
|
|
||||||
import {
|
|
||||||
ListDocumentsSchema,
|
|
||||||
GetDocumentSchema,
|
|
||||||
CreateDocumentSchema,
|
|
||||||
UpdateDocumentSchema,
|
|
||||||
DeleteDocumentSchema,
|
|
||||||
SubmitDocumentSchema,
|
|
||||||
CancelDocumentSchema,
|
|
||||||
SearchLinkSchema,
|
|
||||||
GetCountSchema,
|
|
||||||
CallMethodSchema,
|
|
||||||
GetDocTypeMetaSchema,
|
|
||||||
RunReportSchema,
|
|
||||||
BulkUpdateSchema,
|
|
||||||
WorkflowActionSchema,
|
|
||||||
} from "../schemas/common.js";
|
|
||||||
|
|
||||||
export function registerCoreTools(server: McpServer, client: ERPNextClient): void {
|
|
||||||
|
|
||||||
// ─── 1. List Documents ─────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_list_documents",
|
|
||||||
{
|
|
||||||
title: "List ERPNext Documents",
|
|
||||||
description: `List documents of any DocType with filtering, sorting, pagination and field selection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name (e.g. "Sales Order", "Customer", "Item")
|
|
||||||
- fields: Array of field names to return (default: ["name"])
|
|
||||||
- filters: Array of [field, operator, value] tuples (e.g. [["status","=","Open"]])
|
|
||||||
- order_by: Sort string (e.g. "modified desc")
|
|
||||||
- group_by: Group results by field
|
|
||||||
- limit: Max results (1-100, default 20)
|
|
||||||
- offset: Skip N results for pagination
|
|
||||||
|
|
||||||
Returns: Paginated list with total count, items array, and pagination metadata.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- List open sales orders: doctype="Sales Order", filters=[["status","=","To Deliver and Bill"]]
|
|
||||||
- List customers: doctype="Customer", fields=["name","customer_name","territory"]
|
|
||||||
- List items by group: doctype="Item", filters=[["item_group","=","Products"]]`,
|
|
||||||
inputSchema: ListDocumentsSchema,
|
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const result = await client.listDocuments(params.doctype, {
|
|
||||||
fields: params.fields,
|
|
||||||
filters: params.filters?.map(([f, op, val]) => [params.doctype, f, op, val as string]),
|
|
||||||
order_by: params.order_by,
|
|
||||||
group_by: params.group_by,
|
|
||||||
limit_start: params.offset,
|
|
||||||
limit_page_length: params.limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = formatListMarkdown(result.items, params.doctype, result);
|
|
||||||
return { content: [{ type: "text", text: truncateResponse(text) }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 2. Get Document ───────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_get_document",
|
|
||||||
{
|
|
||||||
title: "Get ERPNext Document",
|
|
||||||
description: `Retrieve a single document by DocType and name with all or selected fields.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- name: Document name/ID
|
|
||||||
- fields: Optional array of specific fields to return
|
|
||||||
|
|
||||||
Returns: Full document data including child tables.`,
|
|
||||||
inputSchema: GetDocumentSchema,
|
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const doc = await client.getDocument(params.doctype, params.name, params.fields);
|
|
||||||
const text = formatDocumentMarkdown(doc, params.doctype);
|
|
||||||
return { content: [{ type: "text", text: truncateResponse(text) }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 3. Create Document ────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_create_document",
|
|
||||||
{
|
|
||||||
title: "Create ERPNext Document",
|
|
||||||
description: `Create a new document of any DocType.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- data: Object with field values. For child tables, pass array of objects under the table fieldname.
|
|
||||||
|
|
||||||
Returns: Created document with assigned name.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- Create customer: doctype="Customer", data={"customer_name":"Acme Corp","customer_type":"Company","customer_group":"Commercial","territory":"All Territories"}
|
|
||||||
- Create item: doctype="Item", data={"item_code":"ITEM-001","item_name":"Widget","item_group":"Products","stock_uom":"Nos"}`,
|
|
||||||
inputSchema: CreateDocumentSchema,
|
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const doc = await client.createDocument(params.doctype, params.data);
|
|
||||||
return { content: [{ type: "text", text: `Created ${params.doctype}: ${doc.name}\n\n${formatDocumentMarkdown(doc, params.doctype)}` }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 4. Update Document ────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_update_document",
|
|
||||||
{
|
|
||||||
title: "Update ERPNext Document",
|
|
||||||
description: `Update an existing document. Only pass the fields you want to change (PATCH semantics).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- name: Document name/ID
|
|
||||||
- data: Object with fields to update
|
|
||||||
|
|
||||||
Returns: Updated document.`,
|
|
||||||
inputSchema: UpdateDocumentSchema,
|
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const doc = await client.updateDocument(params.doctype, params.name, params.data);
|
|
||||||
return { content: [{ type: "text", text: `Updated ${params.doctype}: ${params.name}\n\n${formatDocumentMarkdown(doc, params.doctype)}` }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 5. Delete Document ────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_delete_document",
|
|
||||||
{
|
|
||||||
title: "Delete ERPNext Document",
|
|
||||||
description: `Permanently delete a document. This cannot be undone.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- name: Document name/ID`,
|
|
||||||
inputSchema: DeleteDocumentSchema,
|
|
||||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
await client.deleteDocument(params.doctype, params.name);
|
|
||||||
return { content: [{ type: "text", text: `Deleted ${params.doctype}: ${params.name}` }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 6. Submit Document ────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_submit_document",
|
|
||||||
{
|
|
||||||
title: "Submit ERPNext Document",
|
|
||||||
description: `Submit a submittable document (sets docstatus=1). Used for Sales Orders, Purchase Orders, Invoices, Journal Entries, etc. Submitted documents are locked from editing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- name: Document name/ID`,
|
|
||||||
inputSchema: SubmitDocumentSchema,
|
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const doc = await client.submitDocument(params.doctype, params.name);
|
|
||||||
return { content: [{ type: "text", text: `Submitted ${params.doctype}: ${params.name}\n\n${formatDocumentMarkdown(doc, params.doctype)}` }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 7. Cancel Document ────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_cancel_document",
|
|
||||||
{
|
|
||||||
title: "Cancel ERPNext Document",
|
|
||||||
description: `Cancel a submitted document (sets docstatus=2). Creates reversal entries where applicable (e.g. GL entries for invoices).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- name: Document name/ID`,
|
|
||||||
inputSchema: CancelDocumentSchema,
|
|
||||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const doc = await client.cancelDocument(params.doctype, params.name);
|
|
||||||
return { content: [{ type: "text", text: `Cancelled ${params.doctype}: ${params.name}` }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 8. Search Link ────────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_search_link",
|
|
||||||
{
|
|
||||||
title: "Search ERPNext Link",
|
|
||||||
description: `Search for documents by text — the same search used in Link fields across ERPNext. Returns name + description pairs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType to search in
|
|
||||||
- txt: Search text
|
|
||||||
- filters: Optional additional filters
|
|
||||||
- page_length: Max results (default 20)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- Find customers matching "acme": doctype="Customer", txt="acme"
|
|
||||||
- Find items matching "widget": doctype="Item", txt="widget"`,
|
|
||||||
inputSchema: SearchLinkSchema,
|
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const results = await client.searchLink(
|
|
||||||
params.doctype, params.txt, params.filters, params.page_length
|
|
||||||
);
|
|
||||||
const text = results.length
|
|
||||||
? results.map((r) => `- **${r.value}** — ${r.description || ""}`).join("\n")
|
|
||||||
: `No ${params.doctype} found matching "${params.txt}"`;
|
|
||||||
return { content: [{ type: "text", text }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 9. Get Count ──────────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_get_count",
|
|
||||||
{
|
|
||||||
title: "Count ERPNext Documents",
|
|
||||||
description: `Get the total count of documents matching optional filters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- filters: Optional [field, operator, value] tuples
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- Count open sales orders: doctype="Sales Order", filters=[["status","=","To Deliver and Bill"]]
|
|
||||||
- Count all customers: doctype="Customer"`,
|
|
||||||
inputSchema: GetCountSchema,
|
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const count = await client.getCount(
|
|
||||||
params.doctype,
|
|
||||||
params.filters?.map(([f, op, val]) => [params.doctype, f, op, val as string])
|
|
||||||
);
|
|
||||||
return { content: [{ type: "text", text: `Count of ${params.doctype}: ${count}` }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 10. Call Method ───────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_call_method",
|
|
||||||
{
|
|
||||||
title: "Call ERPNext Method",
|
|
||||||
description: `Call any whitelisted ERPNext/Frappe method via the /api/method/ endpoint. This is the escape hatch for any operation not covered by the other tools.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- method: Dotted method path (e.g. "erpnext.stock.utils.get_stock_balance")
|
|
||||||
- args: Method arguments as key-value object
|
|
||||||
|
|
||||||
Common methods:
|
|
||||||
- frappe.client.get_value: Get a field value from a document
|
|
||||||
- frappe.client.set_value: Set a field value on a document
|
|
||||||
- frappe.client.get_list: Alternative list with more options
|
|
||||||
- erpnext.stock.utils.get_stock_balance: Get stock balance for item/warehouse
|
|
||||||
- erpnext.setup.utils.get_exchange_rate: Get currency exchange rate
|
|
||||||
- erpnext.selling.page.point_of_sale.point_of_sale.get_items: POS items
|
|
||||||
- erpnext.stock.get_item_details.get_item_details: Full item details for transactions`,
|
|
||||||
inputSchema: CallMethodSchema,
|
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const result = await client.callMethod(params.method, params.args);
|
|
||||||
const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
||||||
return { content: [{ type: "text", text: truncateResponse(text) }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 11. Get DocType Schema ────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_get_doctype_meta",
|
|
||||||
{
|
|
||||||
title: "Get DocType Schema/Meta",
|
|
||||||
description: `Get the field definitions and schema for any DocType. Useful to discover available fields before listing or creating documents.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
|
|
||||||
Returns: List of fields with fieldname, fieldtype, label, options, reqd (required), read_only, hidden.`,
|
|
||||||
inputSchema: GetDocTypeMetaSchema,
|
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const meta = await client.getDocTypeMeta(params.doctype);
|
|
||||||
const text = JSON.stringify(meta, null, 2);
|
|
||||||
return { content: [{ type: "text", text: truncateResponse(text) }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 12. Run Report ────────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_run_report",
|
|
||||||
{
|
|
||||||
title: "Run ERPNext Report",
|
|
||||||
description: `Run a Script Report or Query Report and return the data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- report_name: Name of the report (e.g. "General Ledger", "Stock Balance", "Accounts Receivable")
|
|
||||||
- filters: Report filter parameters as key-value object
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- General Ledger: report_name="General Ledger", filters={"company":"My Company","from_date":"2024-01-01","to_date":"2024-12-31"}
|
|
||||||
- Stock Balance: report_name="Stock Balance", filters={"company":"My Company"}
|
|
||||||
- Accounts Receivable: report_name="Accounts Receivable", filters={"company":"My Company"}`,
|
|
||||||
inputSchema: RunReportSchema,
|
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const result = await client.runReport(params.report_name, params.filters);
|
|
||||||
const text = JSON.stringify(result, null, 2);
|
|
||||||
return { content: [{ type: "text", text: truncateResponse(text) }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 13. List DocTypes ─────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_list_doctypes",
|
|
||||||
{
|
|
||||||
title: "List Available DocTypes",
|
|
||||||
description: `List all available DocTypes in the ERPNext instance. Useful to discover what entities exist.
|
|
||||||
|
|
||||||
Returns: Array of DocType names sorted alphabetically.`,
|
|
||||||
inputSchema: {},
|
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const doctypes = await client.getDocTypeList();
|
|
||||||
return { content: [{ type: "text", text: `Available DocTypes (${doctypes.length}):\n\n${doctypes.join("\n")}` }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 14. Bulk Update ───────────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_bulk_update",
|
|
||||||
{
|
|
||||||
title: "Bulk Update ERPNext Documents",
|
|
||||||
description: `Update multiple documents of the same DocType with the same field values.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- names: Array of document names to update
|
|
||||||
- data: Field values to set on all documents
|
|
||||||
|
|
||||||
Example: Set status on multiple issues: doctype="Issue", names=["ISS-001","ISS-002"], data={"status":"Closed"}`,
|
|
||||||
inputSchema: BulkUpdateSchema,
|
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const results: string[] = [];
|
|
||||||
const errors: string[] = [];
|
|
||||||
for (const name of params.names) {
|
|
||||||
try {
|
|
||||||
await client.updateDocument(params.doctype, name, params.data);
|
|
||||||
results.push(name);
|
|
||||||
} catch (error) {
|
|
||||||
errors.push(`${name}: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let text = `Updated ${results.length}/${params.names.length} ${params.doctype} documents.`;
|
|
||||||
if (errors.length) {
|
|
||||||
text += `\n\nErrors:\n${errors.join("\n")}`;
|
|
||||||
}
|
|
||||||
return { content: [{ type: "text", text }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── 15. Workflow Action ───────────────────────────────
|
|
||||||
server.registerTool(
|
|
||||||
"erpnext_workflow_action",
|
|
||||||
{
|
|
||||||
title: "Apply Workflow Action",
|
|
||||||
description: `Apply a workflow action to a document that has a workflow configured.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
- doctype: DocType name
|
|
||||||
- name: Document name
|
|
||||||
- action: Workflow action name (e.g. "Approve", "Reject", "Submit")`,
|
|
||||||
inputSchema: WorkflowActionSchema,
|
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
try {
|
|
||||||
const result = await client.callMethod("frappe.model.workflow.apply_workflow", {
|
|
||||||
doc: { doctype: params.doctype, name: params.name },
|
|
||||||
action: params.action,
|
|
||||||
});
|
|
||||||
return { content: [{ type: "text", text: `Applied workflow action "${params.action}" on ${params.doctype}: ${params.name}` }] };
|
|
||||||
} catch (error) {
|
|
||||||
return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue