@formio/uag-engines 0.0.0-stage → 2.0.0-rc.1
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/LICENSE.txt +19 -0
- package/README.md +82 -2
- package/bin/uag-engines.js +56 -0
- package/index.js +281 -0
- package/network.js +37 -0
- package/package.json +47 -4
- package/research.js +74 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright (c) 2025 Form.io
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
5
|
+
this software and associated documentation files (the "Software"), to deal in the
|
|
6
|
+
Software without restriction, including without limitation the rights to use,
|
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
|
8
|
+
Software, and to permit persons to whom the Software is furnished to do so,
|
|
9
|
+
subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
15
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
16
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
17
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
18
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
19
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @formio/uag-engines
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<sub>[Form.io UAG](https://github.com/formio/uag/blob/main/README.md) › [Engines](https://github.com/formio/uag/blob/main/README.md#engines-and-routing) › this page</sub>
|
|
4
|
+
|
|
5
|
+
Runs engine packages for the Form.io UAG in their own process, behind the remote engine protocol. The UAG reaches them with its built-in `remote` engine type. Model API keys and model traffic then stay in this container, and the UAG holds no model credential. Any engine package works here, unchanged.
|
|
6
|
+
|
|
7
|
+
The engine server holds no Form.io credential. The UAG never sends it one: every request carries only the engine server key. For research, the UAG adds a short-lived scoped token, and the engine server uses it to call the UAG's `/mcp`. The token allows only the task's read-only tools, with the caller's identity.
|
|
8
|
+
|
|
9
|
+
## Run it
|
|
10
|
+
|
|
11
|
+
The `formio/uag-engines` image includes the official engines: `jev`, `claude`, `openai`, `grok` and `gemini`.
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
services:
|
|
15
|
+
formio-uag:
|
|
16
|
+
image: formio/uag:2
|
|
17
|
+
environment:
|
|
18
|
+
UAG_ENGINES: jev:remote,claude:remote
|
|
19
|
+
UAG_ENGINE_JEV_URL: http://uag-engines:3300
|
|
20
|
+
UAG_ENGINE_JEV_KEY: ${UAG_ENGINE_SERVER_KEY}
|
|
21
|
+
UAG_ENGINE_CLAUDE_URL: http://uag-engines:3300
|
|
22
|
+
UAG_ENGINE_CLAUDE_KEY: ${UAG_ENGINE_SERVER_KEY}
|
|
23
|
+
JWT_SECRET: ${JWT_SECRET} # signs the scoped tokens for research
|
|
24
|
+
# ... PROJECT, keys and the rest of the UAG's settings
|
|
25
|
+
depends_on:
|
|
26
|
+
uag-engines:
|
|
27
|
+
condition: service_healthy
|
|
28
|
+
|
|
29
|
+
uag-engines:
|
|
30
|
+
image: formio/uag-engines:2
|
|
31
|
+
environment:
|
|
32
|
+
UAG_ENGINES: jev,claude
|
|
33
|
+
TYPESAFE_API_KEY: ${TYPESAFE_API_KEY}
|
|
34
|
+
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
|
|
35
|
+
UAG_ENGINE_SERVER_KEY: ${UAG_ENGINE_SERVER_KEY}
|
|
36
|
+
UAG_SERVER: http://formio-uag:3200 # where research calls /mcp
|
|
37
|
+
# No published port: only the UAG, on the same private network, reaches it.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
On the UAG, `<name>:remote` is an instance hosted here; by default it uses the engine of the same name on the engine server (`UAG_ENGINE_<NAME>_ENGINE` names another). Any mix works: `UAG_ENGINES=jev,claude:remote` keeps `jev` in the UAG, with its key there, and moves only Claude.
|
|
41
|
+
|
|
42
|
+
Without Docker: `npm install @formio/uag-engines @formio/uag-claude`, then run `npx uag-engines` with the same variables.
|
|
43
|
+
|
|
44
|
+
## Settings
|
|
45
|
+
|
|
46
|
+
| Variable | Meaning | Default |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `UAG_ENGINE_SERVER_KEY` | **Required.** The key every request must carry, at least 32 characters. The UAG's `remote` instances send it as `UAG_ENGINE_<NAME>_KEY`. | |
|
|
49
|
+
| `UAG_ENGINES` | The engine instances to host, as on the UAG: `jev,fast:claude,local:openai`. | |
|
|
50
|
+
| `UAG_ENGINE_<NAME>_<SETTING>` and each engine's own variables | Each instance's settings, resolved as on the UAG. See each engine's README. | |
|
|
51
|
+
| `UAG_SERVER` | The UAG's URL, for research. Research calls go to `${UAG_SERVER}/mcp`, never to a URL taken from a request. Without it, research extracts are refused. | |
|
|
52
|
+
| `UAG_ENGINE_PATH` | Absolute directories with more engine packages installed, such as a mounted volume. | |
|
|
53
|
+
| `PORT`, `HOST` | Where it listens. The `formio/uag-engines` image sets `HOST=0.0.0.0`. | `3300`, `127.0.0.1` |
|
|
54
|
+
| `SHUTDOWN_TIMEOUT` | On SIGTERM or SIGINT, how long to let requests in flight finish before exiting, in milliseconds. | `10000` |
|
|
55
|
+
| `DEBUG` | `formio:uag:engine-server` also logs each engine failure's stack. Without it, each failure is one line on stderr: the request id, the route, and the error's name, status and message, never the request. | |
|
|
56
|
+
|
|
57
|
+
Engines are resolved as on the UAG: an instance named in `UAG_ENGINES` is looked up as `@formio/uag-<type>`, then `uag-engine-<type>`, from the `node_modules` of this package's installation and the directories above it, then from `<dir>/node_modules` of each directory in `UAG_ENGINE_PATH`; never from the working directory, `NODE_PATH` or Node's global folders.
|
|
58
|
+
|
|
59
|
+
## Security
|
|
60
|
+
|
|
61
|
+
- Every route, including `/health`, needs `Authorization: Bearer <UAG_ENGINE_SERVER_KEY>`. The key is compared in constant time, before the request body is read.
|
|
62
|
+
- Keep the engine server on a private network that only the UAG reaches, and do not publish its port. It warns at startup when it listens on a public address.
|
|
63
|
+
- Research uses only the tools that the request asks for and the UAG's `/mcp` lists for the scoped token. Every call is recorded, and the calls this server made are what it reports back, whatever the engine says. No model can write: the token allows only read-only tools.
|
|
64
|
+
- When the UAG abandons a request (its routing timeout), the engine is aborted, research stops (also while it is still connecting), and the MCP connection is closed.
|
|
65
|
+
- Engine errors are passed on only when the engine marks them safe to show (`EngineError`); anything else is reported as "The engine failed". Every failure is logged here in one line, with the error's message: a provider SDK's message can name an account or a URL, so treat these logs as private.
|
|
66
|
+
|
|
67
|
+
## Protocol
|
|
68
|
+
|
|
69
|
+
`GET /health`, `GET /v1/engines`, `POST /v1/engines/:name/decide` and `POST /v1/engines/:name/extract`, all JSON, with `protocol: 1` in every body. See [`@formio/uag-sdk/protocol`](https://github.com/formio/uag/blob/main/packages/engine-sdk/protocol.js) for the shapes.
|
|
70
|
+
|
|
71
|
+
Decide and extract calls may carry the Agent API request's `X-Request-Id`. The engine server sends it on with every research request to the UAG's `/mcp` and puts it in its logs, so a logging proxy can group every leg of a run. Only a short token (up to 128 letters, digits, `.`, `_`, `:` or `-`) is passed on; the header is optional either way, and the protocol version is unchanged.
|
|
72
|
+
|
|
73
|
+
## Building the image
|
|
74
|
+
|
|
75
|
+
From the repository root:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
docker build -f packages/engine-server/Dockerfile -t formio/uag-engines .
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
To add an engine package, either derive an image (the image runs as `node`, so switch to `USER root` for `RUN npm install --prefix /engines uag-engine-acme`, then back to `USER node`, with `ENV UAG_ENGINE_PATH=/engines`), or mount a directory that contains `node_modules/uag-engine-acme` and list it in `UAG_ENGINE_PATH`.
|
|
82
|
+
|
|
83
|
+
License: MIT.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* uag-engines: host engine packages for the Form.io UAG behind the remote
|
|
4
|
+
* engine protocol.
|
|
5
|
+
*
|
|
6
|
+
* UAG_ENGINES=claude CLAUDE_API_KEY=... UAG_ENGINE_SERVER_KEY=... \
|
|
7
|
+
* UAG_SERVER=http://formio-uag:3200 uag-engines
|
|
8
|
+
*
|
|
9
|
+
* PORT (default 3300) and HOST (default 127.0.0.1; the Docker image sets
|
|
10
|
+
* 0.0.0.0) choose where it listens. SIGTERM and SIGINT stop it once the
|
|
11
|
+
* requests in flight finish, or after SHUTDOWN_TIMEOUT milliseconds (10000).
|
|
12
|
+
*/
|
|
13
|
+
const { createEngineServer } = require('..');
|
|
14
|
+
const { publicAddresses } = require('../network');
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
const { app, engines, uagServer } = await createEngineServer();
|
|
18
|
+
const port = Number(process.env.PORT || 3300);
|
|
19
|
+
const host = process.env.HOST || '127.0.0.1';
|
|
20
|
+
const server = await new Promise((resolve, reject) => {
|
|
21
|
+
const listening = app.listen(port, host, () => resolve(listening));
|
|
22
|
+
listening.on('error', reject);
|
|
23
|
+
});
|
|
24
|
+
stopOnSignals(server);
|
|
25
|
+
console.log(`[uag-engines] Serving ${engines.map(({ engine }) => `${engine.name} (${engine.kind}, ${engine.model})`).join(', ')} on ${host}:${port}.`);
|
|
26
|
+
if (!uagServer) {
|
|
27
|
+
console.log('[uag-engines] UAG_SERVER is not set: research extracts are refused.');
|
|
28
|
+
}
|
|
29
|
+
const exposed = publicAddresses(host);
|
|
30
|
+
if (exposed.length) {
|
|
31
|
+
console.warn(`[uag-engines] Warning: listening on public address(es) ${exposed.join(', ')}. Keep the engine server on a private network that only the UAG can reach.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stop accepting requests on SIGTERM or SIGINT, and exit when those in flight finish. */
|
|
36
|
+
function stopOnSignals(server) {
|
|
37
|
+
let stopping = false;
|
|
38
|
+
const stop = (signal) => {
|
|
39
|
+
if (stopping) process.exit(1);
|
|
40
|
+
stopping = true;
|
|
41
|
+
console.log(`[uag-engines] ${signal}: finishing the requests in flight.`);
|
|
42
|
+
server.close(() => process.exit(0));
|
|
43
|
+
server.closeIdleConnections();
|
|
44
|
+
setTimeout(() => {
|
|
45
|
+
console.error('[uag-engines] Requests still in flight at the shutdown timeout; exiting.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}, Number(process.env.SHUTDOWN_TIMEOUT || 10000)).unref();
|
|
48
|
+
};
|
|
49
|
+
process.on('SIGTERM', stop);
|
|
50
|
+
process.on('SIGINT', stop);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch((err) => {
|
|
54
|
+
console.error(`[uag-engines] Failed to start: ${err && err.message ? err.message : err}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const { engineInstances } = require('@formio/uag-sdk');
|
|
6
|
+
const { enginePaths, loadEngines } = require('@formio/uag-sdk/loader');
|
|
7
|
+
const {
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
REQUEST_ID_HEADER,
|
|
10
|
+
SCOPED_TOKEN_HEADER,
|
|
11
|
+
ProtocolError,
|
|
12
|
+
checkDecideRequest,
|
|
13
|
+
checkExtractRequest,
|
|
14
|
+
errorBody,
|
|
15
|
+
safeRequestId,
|
|
16
|
+
} = require('@formio/uag-sdk/protocol');
|
|
17
|
+
const { openResearch, connectMcp } = require('./research');
|
|
18
|
+
const debug = require('debug')('formio:uag:engine-server');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The UAG engine server: hosts engine instances, resolved and configured as on
|
|
22
|
+
* the UAG, behind the remote engine protocol. The UAG's `remote` engine type
|
|
23
|
+
* calls it, so model credentials and model traffic stay in this process.
|
|
24
|
+
*
|
|
25
|
+
* It holds no Form.io credential. For research it connects back to the UAG's
|
|
26
|
+
* `/mcp` with the scoped token the UAG sent, at the UAG URL it is configured
|
|
27
|
+
* with, never one taken from a request. The UAG's request id, when it sends a
|
|
28
|
+
* short token, goes on with that connection and into this server's logs.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const MIN_KEY_LENGTH = 32;
|
|
32
|
+
const ROOT = __dirname;
|
|
33
|
+
|
|
34
|
+
/** An error as one short log line: its name, status and the first line of its message. */
|
|
35
|
+
function failureLine(err) {
|
|
36
|
+
if (!err || typeof err !== 'object') return String(err).slice(0, 300);
|
|
37
|
+
const status = err.status || err.statusCode;
|
|
38
|
+
const message = String(err.message || '').split('\n')[0].slice(0, 300);
|
|
39
|
+
return `${err.name || 'Error'}${status ? ` (${status})` : ''}: ${message}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Thrown for a configuration the server cannot start with. */
|
|
43
|
+
class EngineServerConfigError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'EngineServerConfigError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An error answered with a status and the protocol's error body. */
|
|
51
|
+
class HttpError extends Error {
|
|
52
|
+
constructor(status, message, expose = true) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.expose = expose;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const digest = (value) => crypto.createHash('sha256').update(String(value)).digest();
|
|
60
|
+
|
|
61
|
+
/** Compare a presented key with the server key in constant time, whatever their lengths. */
|
|
62
|
+
function keyMatches(presented, key) {
|
|
63
|
+
return typeof presented === 'string' && crypto.timingSafeEqual(digest(presented), digest(key));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The engine server key, refusing a missing or short one. */
|
|
67
|
+
function serverKey(env) {
|
|
68
|
+
const key = env.UAG_ENGINE_SERVER_KEY;
|
|
69
|
+
if (!key) {
|
|
70
|
+
throw new EngineServerConfigError('UAG_ENGINE_SERVER_KEY is not set. The engine server refuses to start without a key.');
|
|
71
|
+
}
|
|
72
|
+
if (key.length < MIN_KEY_LENGTH) {
|
|
73
|
+
throw new EngineServerConfigError(`UAG_ENGINE_SERVER_KEY must be at least ${MIN_KEY_LENGTH} characters.`);
|
|
74
|
+
}
|
|
75
|
+
return key;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What `GET /v1/engines` says about one engine. */
|
|
79
|
+
function describe(engine) {
|
|
80
|
+
return {
|
|
81
|
+
name: engine.name,
|
|
82
|
+
kind: engine.kind,
|
|
83
|
+
model: engine.model,
|
|
84
|
+
...(engine.kind === 'decision' && { maxOptions: engine.maxOptions }),
|
|
85
|
+
...(engine.kind === 'agent' && { toolUse: engine.toolUse === true }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An abort signal that fires when the client goes away before the answer is sent. */
|
|
90
|
+
function requestSignal(res) {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
res.on('close', () => {
|
|
93
|
+
if (!res.writableFinished) controller.abort(new Error('The UAG closed the request.'));
|
|
94
|
+
});
|
|
95
|
+
return controller.signal;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The UAG's request id for a call, when it sent one that is safe to pass on and log. */
|
|
99
|
+
function requestIdOf(req) {
|
|
100
|
+
return safeRequestId(req.get(REQUEST_ID_HEADER));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Only the keys asked for. */
|
|
104
|
+
function pick(object, keys) {
|
|
105
|
+
return Object.fromEntries(Object.entries(object || {}).filter(([key]) => keys.has(key)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the engine server's Express app for already-loaded engines.
|
|
110
|
+
*
|
|
111
|
+
* Options:
|
|
112
|
+
* - `engines`: `{ engine }` entries from the SDK loader;
|
|
113
|
+
* - `key`: the engine server key;
|
|
114
|
+
* - `uagServer`: the UAG's base URL, for research;
|
|
115
|
+
* - `connect(url, token, requestId)`: the MCP client factory (for tests).
|
|
116
|
+
*/
|
|
117
|
+
function engineServerApp({ engines, key, uagServer, connect = connectMcp }) {
|
|
118
|
+
const byName = new Map(engines.map(({ engine }) => [engine.name, engine]));
|
|
119
|
+
const app = express();
|
|
120
|
+
app.disable('x-powered-by');
|
|
121
|
+
|
|
122
|
+
// The key is checked before anything else, including reading the body.
|
|
123
|
+
app.use((req, res, next) => {
|
|
124
|
+
const presented = /^Bearer (.+)$/.exec(req.get('authorization') || '')?.[1];
|
|
125
|
+
if (!keyMatches(presented, key)) {
|
|
126
|
+
res.status(401).json(errorBody('Unauthorized.', true));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
next();
|
|
130
|
+
});
|
|
131
|
+
app.use(express.json({ limit: '5mb' }));
|
|
132
|
+
|
|
133
|
+
app.get('/health', (req, res) => {
|
|
134
|
+
res.json({ status: 'ok', engines: byName.size });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.get('/v1/engines', (req, res) => {
|
|
138
|
+
res.json({ protocol: PROTOCOL_VERSION, engines: [...byName.values()].map(describe) });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function engineFor(name, kind) {
|
|
142
|
+
const engine = byName.get(name);
|
|
143
|
+
if (!engine) {
|
|
144
|
+
throw new HttpError(404, `This engine server does not host an engine named "${name}".`);
|
|
145
|
+
}
|
|
146
|
+
if (engine.kind !== kind) {
|
|
147
|
+
throw new HttpError(400, `The engine "${name}" is a ${engine.kind} engine.`);
|
|
148
|
+
}
|
|
149
|
+
return engine;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
app.post('/v1/engines/:name/decide', async (req, res, next) => {
|
|
153
|
+
try {
|
|
154
|
+
const engine = engineFor(req.params.name, 'decision');
|
|
155
|
+
const { state, questions } = checkDecideRequest(req.body);
|
|
156
|
+
const response = await engine.decide({ state, questions, signal: requestSignal(res) });
|
|
157
|
+
res.json({
|
|
158
|
+
protocol: PROTOCOL_VERSION,
|
|
159
|
+
model: response.model || engine.model,
|
|
160
|
+
answers: pick(response.answers, new Set(Object.keys(questions))),
|
|
161
|
+
...(response.usage && { usage: response.usage }),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
next(err);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
app.post('/v1/engines/:name/extract', async (req, res, next) => {
|
|
170
|
+
let research;
|
|
171
|
+
try {
|
|
172
|
+
const engine = engineFor(req.params.name, 'agent');
|
|
173
|
+
const request = checkExtractRequest(req.body);
|
|
174
|
+
const signal = requestSignal(res);
|
|
175
|
+
if (request.tools) {
|
|
176
|
+
const token = req.get(SCOPED_TOKEN_HEADER);
|
|
177
|
+
if (!token) {
|
|
178
|
+
throw new HttpError(400, `A research extract needs a scoped token in the ${SCOPED_TOKEN_HEADER} header.`);
|
|
179
|
+
}
|
|
180
|
+
if (!engine.toolUse) {
|
|
181
|
+
throw new HttpError(400, `The engine "${engine.name}" does not research with tools.`);
|
|
182
|
+
}
|
|
183
|
+
if (!uagServer) {
|
|
184
|
+
throw new HttpError(503, 'This engine server cannot research: UAG_SERVER is not set.');
|
|
185
|
+
}
|
|
186
|
+
research = await openResearch({ uagServer, token, requestId: requestIdOf(req), requested: request.tools, signal, connect });
|
|
187
|
+
}
|
|
188
|
+
const response = await engine.extract({
|
|
189
|
+
instructions: request.instructions,
|
|
190
|
+
state: request.state,
|
|
191
|
+
fields: request.fields,
|
|
192
|
+
schema: request.schema,
|
|
193
|
+
signal,
|
|
194
|
+
...(research && { tools: research.tools, callTool: research.callTool, maxIterations: request.maxIterations }),
|
|
195
|
+
});
|
|
196
|
+
res.json({
|
|
197
|
+
protocol: PROTOCOL_VERSION,
|
|
198
|
+
model: response.model || engine.model,
|
|
199
|
+
values: pick(response.values, new Set(Object.keys((request.schema && request.schema.properties) || {}))),
|
|
200
|
+
// The calls this server made for the engine, not what the engine reports.
|
|
201
|
+
...(research && { toolCalls: research.calls }),
|
|
202
|
+
...(response.usage && { usage: response.usage }),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
next(err);
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
if (research) await research.close();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
app.use((req, res) => {
|
|
214
|
+
res.status(404).json(errorBody(`No route for ${req.method} ${req.path}.`, true));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// eslint-disable-next-line no-unused-vars
|
|
218
|
+
app.use((err, req, res, _next) => {
|
|
219
|
+
if (res.headersSent || res.destroyed) return;
|
|
220
|
+
if (err instanceof HttpError || err instanceof ProtocolError) {
|
|
221
|
+
res.status(err.status).json(errorBody(err.message, true));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (err && err.type === 'entity.parse.failed') {
|
|
225
|
+
res.status(400).json(errorBody('The request body is not valid JSON.', true));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (err && err.type === 'entity.too.large') {
|
|
229
|
+
res.status(413).json(errorBody('The request body is too large.', true));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Any other request body problem (an unsupported charset or encoding, an aborted upload).
|
|
233
|
+
if (err && typeof err.type === 'string' && Number.isInteger(err.status) && err.status >= 400 && err.status < 500) {
|
|
234
|
+
res.status(err.status).json(errorBody(err.expose === true ? err.message : 'The request body could not be read.', true));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Always one line, so a refused provider key or a quota shows in the
|
|
238
|
+
// default logs: the error's name and message, never the request.
|
|
239
|
+
console.error(`[uag-engines] [${requestIdOf(req) || '-'}] ${req.method} ${req.path} failed: ${failureLine(err)}`);
|
|
240
|
+
debug(`[${requestIdOf(req) || '-'}] Engine failure on ${req.method} ${req.path}: ${err && err.stack ? err.stack : err}`);
|
|
241
|
+
// Engines mark the messages that are safe to show; everything else stays here.
|
|
242
|
+
res.status(502).json(err && err.expose === true
|
|
243
|
+
? errorBody(err.message, true)
|
|
244
|
+
: errorBody('The engine failed (see the engine server logs).', false));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return app;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Load the engines and build the app from the environment: `UAG_ENGINES` (and
|
|
252
|
+
* each instance's `UAG_ENGINE_*` settings), `UAG_ENGINE_SERVER_KEY`,
|
|
253
|
+
* `UAG_SERVER` and `UAG_ENGINE_PATH`.
|
|
254
|
+
*/
|
|
255
|
+
async function createEngineServer({ env = process.env, config = {}, types = {}, root = ROOT, connect } = {}) {
|
|
256
|
+
const key = serverKey(env);
|
|
257
|
+
const uagServer = env.UAG_SERVER ? String(env.UAG_SERVER) : undefined;
|
|
258
|
+
if (uagServer && !/^https?:\/\//.test(uagServer)) {
|
|
259
|
+
throw new EngineServerConfigError('UAG_SERVER must be an http or https URL.');
|
|
260
|
+
}
|
|
261
|
+
const engines = await loadEngines(engineInstances(config, env), {
|
|
262
|
+
types,
|
|
263
|
+
paths: enginePaths(root, env),
|
|
264
|
+
env,
|
|
265
|
+
config,
|
|
266
|
+
});
|
|
267
|
+
if (!engines.length) {
|
|
268
|
+
throw new EngineServerConfigError('UAG_ENGINES names no engines, so there is nothing to serve.');
|
|
269
|
+
}
|
|
270
|
+
return { app: engineServerApp({ engines, key, uagServer, connect }), engines, uagServer };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
MIN_KEY_LENGTH,
|
|
275
|
+
EngineServerConfigError,
|
|
276
|
+
createEngineServer,
|
|
277
|
+
engineServerApp,
|
|
278
|
+
keyMatches,
|
|
279
|
+
describe,
|
|
280
|
+
root: path.resolve(ROOT),
|
|
281
|
+
};
|
package/network.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const net = require('net');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether an address is private: loopback, link-local, RFC 1918, carrier-grade
|
|
7
|
+
* NAT, or an IPv6 unique-local address.
|
|
8
|
+
*/
|
|
9
|
+
function isPrivateAddress(address) {
|
|
10
|
+
const ip = String(address).replace(/^::ffff:/, '').split('%')[0];
|
|
11
|
+
if (net.isIPv4(ip)) {
|
|
12
|
+
const [a, b] = ip.split('.').map(Number);
|
|
13
|
+
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
|
14
|
+
|| (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
|
|
15
|
+
}
|
|
16
|
+
if (net.isIPv6(ip)) {
|
|
17
|
+
const lower = ip.toLowerCase();
|
|
18
|
+
return lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80');
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The public addresses a server bound to `host` is reachable on: `host` itself
|
|
25
|
+
* when it is public, or every public interface address for a wildcard bind.
|
|
26
|
+
*/
|
|
27
|
+
function publicAddresses(host, interfaces = os.networkInterfaces()) {
|
|
28
|
+
const wildcard = !host || host === '0.0.0.0' || host === '::';
|
|
29
|
+
if (!wildcard) {
|
|
30
|
+
return net.isIP(host) && !isPrivateAddress(host) ? [host] : [];
|
|
31
|
+
}
|
|
32
|
+
return Object.values(interfaces).flat()
|
|
33
|
+
.filter((entry) => entry && !entry.internal && !isPrivateAddress(entry.address))
|
|
34
|
+
.map((entry) => entry.address);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { isPrivateAddress, publicAddresses };
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formio/uag-engines",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.0-rc.1",
|
|
4
|
+
"description": "Host engine packages for the Form.io Universal Agent Gateway (UAG) in their own process, behind the remote engine protocol, so model credentials and model traffic stay out of the UAG.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"formio",
|
|
7
|
+
"uag",
|
|
8
|
+
"universal-agent-gateway",
|
|
9
|
+
"ai",
|
|
10
|
+
"agents",
|
|
11
|
+
"engine",
|
|
12
|
+
"server",
|
|
13
|
+
"mcp"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"bin": "bin/uag-engines.js",
|
|
18
|
+
"files": [
|
|
19
|
+
"index.js",
|
|
20
|
+
"network.js",
|
|
21
|
+
"research.js",
|
|
22
|
+
"bin",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "mocha"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.0.0"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/formio/uag.git",
|
|
34
|
+
"directory": "packages/engine-server"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@formio/uag-sdk": "^2.0.0-rc.1",
|
|
41
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
42
|
+
"debug": "^4.4.3",
|
|
43
|
+
"express": "^5.3.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"chai": "^6.3.0",
|
|
47
|
+
"mocha": "^12.0.3"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/research.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { REQUEST_ID_HEADER } = require('@formio/uag-sdk/protocol');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Research for an extract request: an MCP connection to the UAG's `/mcp`,
|
|
6
|
+
* authenticated with the scoped token the UAG sent. The UAG decides what the
|
|
7
|
+
* token allows (the task's read-only tools, with the caller's identity); this
|
|
8
|
+
* side offers the model only the tools that were both requested and listed
|
|
9
|
+
* there, and records every call.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Connect an MCP client to `url` with `token` as its bearer token, and the UAG's request id when there is one. */
|
|
13
|
+
async function connectMcp(url, token, requestId) {
|
|
14
|
+
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
|
15
|
+
const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
|
16
|
+
const client = new Client({ name: 'uag-engines', version: require('./package.json').version });
|
|
17
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(url), {
|
|
18
|
+
requestInit: { headers: { Authorization: `Bearer ${token}`, ...(requestId && { [REQUEST_ID_HEADER]: requestId }) } },
|
|
19
|
+
}));
|
|
20
|
+
return client;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Open research: connect, intersect the tools, and return what the engine's
|
|
25
|
+
* extract needs, the calls made, and `close`, which is safe to call twice.
|
|
26
|
+
*/
|
|
27
|
+
async function openResearch({ uagServer, token, requestId, requested, signal, connect = connectMcp }) {
|
|
28
|
+
signal.throwIfAborted();
|
|
29
|
+
const client = await connect(`${uagServer.replace(/\/+$/, '')}/mcp`, token, requestId);
|
|
30
|
+
let closed = false;
|
|
31
|
+
const close = async () => {
|
|
32
|
+
if (closed) return;
|
|
33
|
+
closed = true;
|
|
34
|
+
await client.close().catch(() => {});
|
|
35
|
+
};
|
|
36
|
+
signal.addEventListener('abort', close, { once: true });
|
|
37
|
+
try {
|
|
38
|
+
// The UAG may have given up while this side was connecting.
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
const listed = new Map(((await client.listTools()).tools || []).map((tool) => [tool.name, tool]));
|
|
41
|
+
const names = new Set(requested.map((tool) => tool.name).filter((name) => listed.has(name)));
|
|
42
|
+
const tools = [...names].map((name) => {
|
|
43
|
+
const tool = listed.get(name);
|
|
44
|
+
return { name, description: tool.description || '', inputSchema: tool.inputSchema || { type: 'object' } };
|
|
45
|
+
});
|
|
46
|
+
const calls = [];
|
|
47
|
+
async function callTool(name, args) {
|
|
48
|
+
if (signal.aborted || closed) {
|
|
49
|
+
throw signal.reason || new Error('The research was stopped.');
|
|
50
|
+
}
|
|
51
|
+
if (!names.has(name)) {
|
|
52
|
+
calls.push({ name, isError: true });
|
|
53
|
+
return { content: [{ type: 'text', text: `The tool "${name}" is not available to this task.` }], isError: true };
|
|
54
|
+
}
|
|
55
|
+
let result;
|
|
56
|
+
try {
|
|
57
|
+
result = await client.callTool({ name, arguments: args || {} }, undefined, { signal });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
if (signal.aborted) throw signal.reason || err;
|
|
61
|
+
result = { content: [{ type: 'text', text: err && err.message ? err.message : 'The tool call failed.' }], isError: true };
|
|
62
|
+
}
|
|
63
|
+
calls.push({ name, isError: !!result.isError });
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
return { tools, callTool, calls, close };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
await close();
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { connectMcp, openResearch };
|