@formio/uag 1.11.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,13 @@ The Universal Agent Gateway (UAG) leverages the [Model Context Protocol (MCP)](h
6
6
 
7
7
  ![Diagram showing how the UAG provides an AI Agent the ability to read and understand a Form.io Form JSON. Similar to how the JavaScript renderer provides a Human the ability to visually see a rendered form.](./examples/images/uag-agent-rendering.png)
8
8
 
9
+ Official Documentation
10
+ --------------------------
11
+ For the latest documentation, release information, and guides, always refer to the official Form.io Help Documentation available here:
12
+
13
+ **[https://help.form.io](https://help.form.io/dev/ai/uag)**
14
+
15
+ --------------------------
9
16
  There are two primary scenarios that the UAG enables:
10
17
 
11
18
  - **Unstructured content conversion to deterministic structured data**: This capability uses the JSON schema of a Form.io form to produce a deterministic structured data object provided a Natural Language input.
@@ -80,6 +87,8 @@ In order to a aid in the "agentification" of the UAG, we have also provided some
80
87
 
81
88
  ![Agentic process flow via single API call into Claude Integration](./examples/images/agent-flow.png)
82
89
 
90
+ Each integration runs its own MCP client and connects *out* to the UAG, so the UAG does not need to be publicly accessible for these to work — it only needs to be reachable from the integration, such as over a private Docker network or on `localhost`.
91
+
83
92
  These integrations can be found within the **integrations** folder. The following **integrations** are provided to enable automated Agentic workflows.
84
93
 
85
94
  - [Claude Integration](./integrations/claude)
@@ -165,6 +174,40 @@ The following sections describe the process of creating a running instance of UA
165
174
 
166
175
  This documentation focuses on using UAG with Form.io Open Source. For documentation on using UAG with Form.io Enterprise, refer to [https://help.form.io/uag](https://help.form.io/uag).
167
176
 
177
+ ### How Agents Connect
178
+ Before deploying, it is worth deciding **which side opens the connection** to the UAG's `/mcp` endpoint, because that single decision determines whether the UAG needs to be publicly accessible.
179
+
180
+ | Connection model | Who runs the MCP client | Does the UAG need a public domain? |
181
+ |------------------|-------------------------|------------------------------------|
182
+ | **Local MCP client** | Your own application or integration, running alongside the UAG | **No.** The UAG only needs to be reachable from that process — `http://localhost:3200` or a private Docker network address is fine. |
183
+ | **Remote MCP client** | A hosted agent that dials into your UAG (Claude.ai and Claude Desktop connectors, the Anthropic MCP connector, or any other remote MCP host) | **Yes.** The agent must be able to reach `/mcp` and the OAuth discovery endpoints over the public internet. |
184
+
185
+ With a **local MCP client**, your application connects *out* to the UAG, calls the tools itself, and passes the results on to whichever model it is using. Nothing dials back in, so the UAG can stay entirely inside your network. This is how the [Claude Integration](./integrations/claude) works, and it is the recommended model for server-to-server and webhook-triggered automation — including running the whole stack on a developer machine.
186
+
187
+ With a **remote MCP client**, the agent lives outside your network and connects in, so `/mcp` and the `.well-known` OAuth discovery endpoints must be publicly reachable over HTTPS, and `BASE_URL` must be that public URL. See [Domain and URL Configuration](#domain-and-url-configuration).
188
+
189
+ Both models can be used against the same UAG deployment at the same time.
190
+
191
+ #### Authenticating a local MCP client
192
+ A remote agent authenticates through the browser-based OAuth (PKCE) flow. A local client has no browser, so it exchanges a server-side key for an access token using the `client_credentials` grant against `{{ BASE_URL }}/auth/token`, then sends that token as a `Bearer` token on its `/mcp` requests.
193
+
194
+ The `client_id` must be prefixed with the project name — which is `formio-oss` for Open Source deployments:
195
+
196
+ ```
197
+ POST /auth/token
198
+ Content-Type: application/json
199
+
200
+ {
201
+ "grant_type": "client_credentials",
202
+ "client_id": "formio-oss-x-admin-key",
203
+ "client_secret": "{{ ADMIN_KEY }}"
204
+ }
205
+ ```
206
+
207
+ For Enterprise, use the project name from your project endpoint together with the project API key — for example `"client_id": "myproject-x-token"` with `"client_secret": "{{ PROJECT_KEY }}"`.
208
+
209
+ The response contains an `access_token`, which is passed to the UAG as `Authorization: Bearer {{ access_token }}`. Tokens expire according to `JWT_EXPIRE_TIME`, so long-running clients should cache the token and refresh it before it expires. See [`integrations/claude/auth.js`](./integrations/claude/auth.js) for a working implementation.
210
+
168
211
  ### Runtime Environments
169
212
  There are currently two run-time environments that work with the UAG:
170
213
  -[**Docker**](#docker)
@@ -300,8 +343,8 @@ This module can be configured in many ways. One of those ways is through the use
300
343
  | JWT_EXPIRE_TIME | The expiration for the jwt secret. | 3600 |
301
344
  | MONGO | (Enterprise Only) Allows you to connect the UAG directly to a mongo database, rather than having to redirect the submissions to the Form.io Submission APIs. | |
302
345
  | MONGO_CONFIG | JSON configuration for the Node.js Mongo Driver. | |
303
- | BASE_URL | The public URL that the UAG is hosted on. This allows for proper OIDC authentication and allows for the authentication callbacks to point to the correct url. | https://forms.yoursite.com |
304
- | LOGIN_FORM | The public URL to the Login Form JSON endpoint. | https://mysite.com/project/user/login |
346
+ | BASE_URL | **Required.** The URL that the UAG is reachable at *by its clients*. It is published in the `.well-known` OIDC (PKCE) definitions and used for authentication callbacks. This must be a public URL when remote agents connect in; for a local MCP client, a private or localhost URL is fine. | https://forms.yoursite.com |
347
+ | LOGIN_FORM | The URL to the Login Form JSON endpoint, loaded by the `/auth/authorize` page. Only used by the browser-based login flow, so it must be reachable by that browser. | https://mysite.com/project/user/login |
305
348
  | CORS | The cors domain, or the JSON configuration to configure the "cors" node.js module cross domain resource sharing. | *.* |
306
349
 
307
350
  ### Project Cache and TTL
@@ -315,8 +358,10 @@ Here is a table explaining how this parameter can be used.
315
358
  | PROJECT_TTL | 60 | Check for changes every minute |
316
359
  | PROJECT_TTL | 3600 | Check for changes every hour |
317
360
 
318
- ### Running on Public Domain
319
- In order to run the UAG on a public domain, it is very important to provide the proper configurations so that any AI agent can properly authenticate. There are 3 different "domain" environment variables that matter, and it is important to understand how to configure them depending on your use case:
361
+ ### Domain and URL Configuration
362
+ There are 3 different "domain" environment variables that matter, and it is important to understand how to configure them depending on your use case. Whether any of them need to be *public* depends on how agents connect — see [How Agents Connect](#how-agents-connect).
363
+
364
+ If a remote agent connects into your UAG, these must be configured correctly and publicly, or the agent will not be able to authenticate. If you only use a local MCP client, all three can point at private or localhost addresses.
320
365
 
321
366
  #### PROJECT
322
367
  The ```PROJECT``` environment variable is used to establish a connection from the UAG server to the project endpoint (for Enterprise) or OSS base url. This does NOT need to be a public DNS entry, but rather a URL that connects the UAG container to the Server container. The following examples illustrate how this would be configured.
@@ -400,14 +445,21 @@ services:
400
445
  ```
401
446
 
402
447
  #### BASE_URL
403
- The ```BASE_URL``` is used to communicate to the AI agent the public domain that is hosting the UAG server. This value is provided within the ```.well-known``` definitions for the OIDC (PKCE) authentication. If this is not correct, then the AI agent will not be able to authenticate into the UAG.
448
+ The ```BASE_URL``` tells the UAG what URL it is reachable at. This value is published within the ```.well-known``` definitions for the OIDC (PKCE) authentication, and is used for the authentication callbacks.
449
+
450
+ **```BASE_URL``` is always required**, including for local clients. The UAG's authentication provider is only enabled when it is set, so if you omit it, ```/mcp``` returns a 401 for every request and the ```/auth/token``` and ```/.well-known/*``` endpoints do not exist at all (404). A 404 on ```/auth/token``` is the clearest signal that ```BASE_URL``` is missing.
404
451
 
405
- **This needs to be the publicly accessible domain that you are hosting your UAG.**
452
+ What it needs to be set to depends on how agents connect:
406
453
 
407
- For example, ```BASE_URL: https://forms.mysite.com```.
454
+ - **Remote agents connect in**: this must be the publicly accessible domain hosting your UAG, because that is the address the agent is told to authenticate against. For example, ```BASE_URL: https://forms.mysite.com```.
455
+ - **Local MCP client only**: set it to the address your client uses to reach the UAG. A private or localhost URL is fine — for example ```BASE_URL: http://localhost:3200``` or ```BASE_URL: http://formio-uag:3200``` within a Docker network.
456
+
457
+ If a remote agent cannot authenticate, a mismatched ```BASE_URL``` is the most common cause: the agent reads this value from the discovery document and will follow it, so if it points somewhere the agent cannot reach, authentication fails even though the UAG itself is running correctly.
408
458
 
409
459
  #### LOGIN_FORM
410
- This is the publicly accessible URL to the Login form of your Project or OSS deployment. This provides the URL that is loaded when the user navigates to ```{{ BASE_URL }}/auth/authorize```. If you navigate to this URL, and the page says that you cannot load the form, then this is because your LOGIN_FORM environment variable is not pointing to the correct form JSON endpoint of your project.
460
+ This is the URL to the Login form of your Project or OSS deployment. It provides the form that is loaded when a user navigates to ```{{ BASE_URL }}/auth/authorize```. If you navigate to this URL, and the page says that you cannot load the form, then this is because your LOGIN_FORM environment variable is not pointing to the correct form JSON endpoint of your project.
461
+
462
+ This is only used by the browser-based login flow, so it needs to be reachable by the browser performing that login. A local MCP client authenticating with the `client_credentials` grant never loads this page.
411
463
 
412
464
  For example:
413
465
  - Enterprise Example: ```LOGIN_FORM: https://forms.mysite.com/myproject/user/login```
@@ -89,7 +89,7 @@ export declare class UAGFormInterface extends FormInterface {
89
89
  }[] | {
90
90
  data: {};
91
91
  } | {
92
- data?: undefined;
92
+ data?: {};
93
93
  } | null;
94
94
  /**
95
95
  * Override the process methods for the UAG.
@@ -13,22 +13,23 @@ export declare class UAGProjectInterface extends ProjectInterface {
13
13
  get config(): UAGConfig;
14
14
  constructor(endpoint?: string);
15
15
  initialize(): Promise<void>;
16
+ buildMcpServer(): Promise<McpServer>;
16
17
  addForm(form: Form, machineName: string): UAGFormInterface | undefined;
17
18
  uagRouter(): Router;
18
19
  authorizeRequest(req: SubmissionRequest, res: Response, next: NextFunction): Promise<Response<any, Record<string, any>> | undefined>;
19
20
  mcpJSONResponse(data?: object, isError?: boolean): {
20
- isError?: boolean | undefined;
21
21
  content: {
22
- type: "text";
22
+ type: 'text';
23
23
  text: string;
24
24
  }[];
25
25
  structuredContent: object;
26
+ isError?: boolean | undefined;
26
27
  };
27
28
  mcpResponse(templateName: ResponseTemplate, data?: object, isError?: boolean): {
28
- isError?: boolean | undefined;
29
29
  content: {
30
- type: "text";
30
+ type: 'text';
31
31
  text: string;
32
32
  }[];
33
+ isError?: boolean | undefined;
33
34
  };
34
35
  }
@@ -20,6 +20,12 @@ class UAGProjectInterface extends appserver_1.ProjectInterface {
20
20
  async initialize() {
21
21
  await super.initialize();
22
22
  this.uagTemplate = new template_1.UAGTemplate(this.config?.responseTemplates || {});
23
+ this.mcpServer = await this.buildMcpServer();
24
+ }
25
+ // Build a fresh McpServer with all tools registered. A new instance is created
26
+ // per request so concurrent requests never share (and overwrite) a transport.
27
+ async buildMcpServer() {
28
+ const server = new mcp_js_1.McpServer({ name: 'formio-uag', version: '1.0.0' });
23
29
  // Get the standard UAG tools.
24
30
  const tools = await (0, tools_1.getTools)(this);
25
31
  // Add the custom tools from config.
@@ -29,13 +35,14 @@ class UAGProjectInterface extends appserver_1.ProjectInterface {
29
35
  // Iterate and register all the tools.
30
36
  for (const tool of tools) {
31
37
  if (tool?.name) {
32
- this.mcpServer.registerTool(tool.name, {
38
+ server.registerTool(tool.name, {
33
39
  title: tool.title,
34
40
  description: tool.description,
35
41
  inputSchema: tool.inputSchema
36
42
  }, tool.execute);
37
43
  }
38
44
  }
45
+ return server;
39
46
  }
40
47
  addForm(form, machineName) {
41
48
  const formInterface = super.addForm(form, machineName);
package/lib/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type UAGModule = ServerModule & {
7
7
  };
8
8
  export declare class UAGServer extends Server {
9
9
  constructor(config?: UAGConfig);
10
- router(): Promise<import("express-serve-static-core").Router>;
10
+ router(): Promise<any>;
11
11
  }
12
12
  export * from '@formio/appserver';
13
13
  export * from './config';
package/lib/router.js CHANGED
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.UAGRouter = UAGRouter;
4
4
  const express_1 = require("express");
5
5
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
6
- const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
7
6
  // Helper function to create JSON RPC error responses
8
7
  const createJsonRpcErrorResponse = (code, message) => {
9
8
  return JSON.stringify({
@@ -19,6 +18,8 @@ const createJsonRpcErrorResponse = (code, message) => {
19
18
  * @returns True if the error was handled, false otherwise.
20
19
  */
21
20
  const handleResponseError = (error, res) => {
21
+ // A thrown fetch Response (the global Response, not Express's) carries the
22
+ // upstream status and headers that should be forwarded to the client.
22
23
  if (error instanceof Response) {
23
24
  const fixedHeaders = {};
24
25
  error.headers.forEach((value, key) => {
@@ -31,22 +32,29 @@ const handleResponseError = (error, res) => {
31
32
  res.status(500).json(JSON.parse(createJsonRpcErrorResponse(-32603, "Internal Server Error")));
32
33
  return false;
33
34
  };
35
+ /**
36
+ * Build a per-request McpServer + transport (stateless mode) and tear both down
37
+ * when the response closes. A fresh instance per request keeps concurrent requests
38
+ * from overwriting each other's transport.
39
+ */
40
+ const connectMcpTransport = async (project, res) => {
41
+ const server = await project.buildMcpServer();
42
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
43
+ sessionIdGenerator: undefined, // Use "undefined" to trigger stateless mode.
44
+ });
45
+ res.on('close', () => {
46
+ transport.close();
47
+ server.close();
48
+ });
49
+ await server.connect(transport);
50
+ return transport;
51
+ };
34
52
  function UAGRouter(project) {
35
53
  const router = (0, express_1.Router)();
36
54
  // Handles the MCP post requests.
37
55
  router.post('/', async (req, res) => {
38
56
  try {
39
- const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
40
- sessionIdGenerator: undefined, // Use "undefined" to trigger stateless mode.
41
- });
42
- // Close any existing connection before connecting to a new transport (required in SDK 1.26.0+)
43
- await project.mcpServer.close();
44
- await project.mcpServer.connect(transport);
45
- if (!transport) {
46
- res.status(500).json(JSON.parse(createJsonRpcErrorResponse(-32603, "Unable to create MCP transport.")));
47
- return;
48
- }
49
- (0, types_js_1.isInitializeRequest)(req.body);
57
+ const transport = await connectMcpTransport(project, res);
50
58
  await transport.handleRequest(req, res, req.body);
51
59
  }
52
60
  catch (error) {
@@ -56,16 +64,7 @@ function UAGRouter(project) {
56
64
  // Handles the MCP get requests (for health checks, etc).
57
65
  router.get('/', async (req, res) => {
58
66
  try {
59
- const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
60
- sessionIdGenerator: undefined, // Use "undefined" to trigger stateless mode.
61
- });
62
- // Close any existing connection before connecting to a new transport (required in SDK 1.26.0+)
63
- await project.mcpServer.close();
64
- await project.mcpServer.connect(transport);
65
- if (!transport) {
66
- res.status(500).json(JSON.parse(createJsonRpcErrorResponse(-32603, "Unable to create MCP transport.")));
67
- return;
68
- }
67
+ const transport = await connectMcpTransport(project, res);
69
68
  await transport.handleRequest(req, res);
70
69
  }
71
70
  catch (err) {
@@ -1,4 +1,4 @@
1
- Please read and understand the following **--- CRITERIA ---** section. This section provides instructions on how to analyze the **--- SUBMISSION ---** section that follows. Your objective is to provide values defined within the **--- REQUIRED FIELDS ---** section, and then to submit those values using the `agent_submit_data` tool.
1
+ Please read and understand the following **--- CRITERIA ---** section. This section provides instructions on how to analyze the **--- SUBMISSION ---** section that follows. Your objective is to provide values defined within the **--- REQUIRED FIELDS ---** section, and then to submit those values using the `submission_update` tool.
2
2
 
3
3
  --- CRITERIA ---
4
4
  <%= criteria %>
@@ -1,6 +1,6 @@
1
1
  There are some validation errors in the data collected:
2
2
  <% validationErrors.forEach(function(error) { %>
3
- - <% if (error.label && error.path) { %>**<%= error.label %> (<%= error.path %>)**: <% } %><%= error.message %><% }); %>
3
+ - <% if (error.label && error.path) { %>**<%= error.label %> (<%= error.path %>)**: <% } %><%= error.error || error.message %><% }); %>
4
4
 
5
5
  These validation errors are in one of the following format:
6
6
  - **Field Label (data_path)** - Error message: If the error is attributed to a specific field value.
@@ -21,13 +21,28 @@ const submitCompletedForm = async (project) => {
21
21
  try {
22
22
  let submission = form.convertToSubmission(form_data);
23
23
  const submitted = await form.submit(submission, extra.authInfo);
24
- if (submitted)
24
+ if (submitted) {
25
+ // The submission save proxies to the Form.io server, and a rejection
26
+ // (e.g. failed validation) can come back through the save pipeline as
27
+ // the error response body instead of a saved submission. A result
28
+ // without an _id was not persisted — report the failure to the agent
29
+ // instead of a false success.
30
+ const result = submitted;
31
+ if (!result._id) {
32
+ const errors = (result.name === 'ValidationError' && result.details)
33
+ ? form.convertToFormFieldErrors(result.details)
34
+ : [];
35
+ return project.mcpResponse(template_1.ResponseTemplate.submitValidationError, {
36
+ validationErrors: errors.length ? errors : [{
37
+ message: result.message || 'The submission was not saved by the server. Verify that all required fields have been collected and that every value conforms to its field rules, then try again.'
38
+ }]
39
+ }, true);
40
+ }
25
41
  submission = submitted;
26
- if (!submission) {
27
- return project.mcpResponse(template_1.ResponseTemplate.submitValidationError, {
28
- validationErrors: ['Unknown error during submission']
29
- }, true);
30
42
  }
43
+ // A null result is still a valid outcome for "action" forms that have no
44
+ // Save Submission action (e.g. email or webhook only) — the data was
45
+ // processed but intentionally not persisted, so no submission ID exists.
31
46
  debug(`Form submitted: ${form_name} with submission ID: ${submission._id}`);
32
47
  return project.mcpResponse(template_1.ResponseTemplate.formSubmitted, {
33
48
  form: form.form,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formio/uag",
3
- "version": "1.11.2",
3
+ "version": "1.12.0",
4
4
  "mcpName": "io.form/uag",
5
5
  "description": "The Form.io Universal Agent Gateway (UAG).",
6
6
  "main": "lib/index.js",
@@ -13,11 +13,12 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "clean": "rm -rf lib",
16
- "build:ts": "npx tsc",
16
+ "build:ts": "tsc",
17
17
  "build:copy-templates": "mkdir -p lib/templates && cp src/templates/*.md lib/templates/",
18
18
  "build": "npm run clean && npm run build:ts && npm run build:copy-templates",
19
19
  "build:docker": "docker build -t formio/uag$(node -p \"require('./package.json').version.includes('-rc.') ? ':rc' : ''\") --platform=linux/amd64 .",
20
- "test": "TS_NODE_PROJECT=tsconfig.test.json mocha --no-node-snapshot",
20
+ "test": "tsc --noEmit -p tsconfig.test.json && mocha --no-node-snapshot",
21
+ "test:e2e": "mocha --config .mocharc.e2e.json",
21
22
  "dev": "tsx --no-node-snapshot index.js",
22
23
  "start": "node --no-node-snapshot index.js",
23
24
  "claude": "node --no-node-snapshot ./integrations/claude/index.js"
@@ -29,18 +30,18 @@
29
30
  "author": "Form.io Developers",
30
31
  "license": "MIT",
31
32
  "dependencies": {
32
- "@formio/appserver": "^2.12.2",
33
- "@formio/core": "2.5.1-dev.291.6557e4e",
34
- "@modelcontextprotocol/sdk": "1.27.1",
33
+ "@formio/appserver": "^2.13.0",
34
+ "@formio/core": "2.8.0",
35
+ "@modelcontextprotocol/sdk": "1.30.0",
35
36
  "cors": "^2.8.6",
36
37
  "debug": "^4.4.3",
37
- "dotenv": "^17.3.1",
38
+ "dotenv": "^17.4.2",
38
39
  "express": "^5.2.1",
39
40
  "jsonwebtoken": "^9.0.3",
40
- "lodash": "^4.17.23",
41
+ "lodash": "^4.18.1",
41
42
  "node-cache": "^5.1.2",
42
43
  "node-html-markdown": "^2.0.0",
43
- "zod": "^4.3.6"
44
+ "zod": "^4.4.3"
44
45
  },
45
46
  "resolutions": {
46
47
  "tar": ">=7.5.12",
@@ -51,22 +52,23 @@
51
52
  "dompurify": ">=3.3.3"
52
53
  },
53
54
  "devDependencies": {
55
+ "@anthropic-ai/sdk": "^0.115.0",
54
56
  "@types/chai": "^5.2.3",
55
57
  "@types/cors": "^2.8.19",
56
- "@types/debug": "^4.1.12",
58
+ "@types/debug": "^4.1.13",
57
59
  "@types/dotenv": "^8.2.3",
58
60
  "@types/express": "^5.0.6",
59
61
  "@types/jsonwebtoken": "^9.0.10",
60
62
  "@types/lodash": "^4.17.24",
61
63
  "@types/mocha": "^10.0.10",
62
- "@types/node": "^25.5.0",
63
- "@types/supertest": "^7.2.0",
64
+ "@types/node": "^26.1.2",
65
+ "@types/supertest": "^7.2.1",
64
66
  "chai": "^6.2.2",
65
- "mocha": "^11.7.5",
67
+ "mocha": "^11.7.6",
66
68
  "supertest": "^7.2.2",
67
- "terser": "^5.46.1",
68
- "ts-node": "^10.9.2",
69
- "tsx": "^4.21.0",
70
- "typescript": "^5.9.3"
71
- }
69
+ "terser": "^5.49.0",
70
+ "tsx": "^4.23.1",
71
+ "typescript": "^7.0.2"
72
+ },
73
+ "packageManager": "yarn@4.17.1"
72
74
  }