@anteros/core 0.0.41 → 0.0.44

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.
Files changed (133) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +724 -58
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
package/server/boot.ts CHANGED
@@ -14,6 +14,7 @@ import { syncFileCollections } from '../database/file'
14
14
  import { syncVars } from '../database/vars'
15
15
  import { startReplication, stopReplication } from '../database/replication'
16
16
  import { loadLifecycles, runBeforeBoot, runAfterBoot, runOnDestroy } from '../lib/lifecycle'
17
+ import { workflowStats } from '../lib/workflow'
17
18
 
18
19
  import { loadRoutes } from '../lib/routes'
19
20
  import { runScripts } from '../lib/scripts'
@@ -22,6 +23,7 @@ import { loadServices } from '../lib/services'
22
23
  import { loadSockets } from '../lib/sockets'
23
24
  import { syncWorkflows } from '../lib/workflow'
24
25
  import { syncMcpTools } from '../lib/mcp'
26
+ import { syncAgents, agentsStats } from '../lib/agents'
25
27
  import { loadTenantsMiddlewares } from '../lib/middleware'
26
28
  import crypto from 'node:crypto';
27
29
  import os from 'node:os';
@@ -87,6 +89,14 @@ function renderBanner(opts: {
87
89
  }
88
90
  box += `Replication: ${replicationInfo}`.gray.bold + '\n'
89
91
 
92
+ // Workflows loaded from `{tenant.dir}/workflows/**/*.workflow.ts`
93
+ const wf = workflowStats();
94
+ box += `Workflows: ${wf.total ? `${wf.total}`.green.bold + ` (${wf.tenants.join(', ')})`.gray : 'none'.gray}\n`.gray.bold
95
+
96
+ // Agents loaded from `{tenant.dir}/agents/**/*.agent.ts`
97
+ const ag = agentsStats();
98
+ box += `Agents: ${ag.total ? `${ag.total}`.green.bold + ` (${ag.tenants.join(', ')})`.gray : 'none'.gray}\n`.gray.bold
99
+
90
100
  // Only shown when something is disabled — the default banner stays compact
91
101
  if (off.length) {
92
102
  box += `Mode: ${capabilitiesLabel(caps)}`.gray.bold + ` (${off.join(', ')} off)`.yellow + '\n';
@@ -144,6 +154,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
144
154
  if (caps.sockets) await loadSockets(); // load websocket handlers
145
155
  await syncWorkflows();
146
156
  await syncMcpTools(); // load MCP tools per tenant (mcp/**/*.tool.ts)
157
+ await syncAgents(); // load agents per tenant (agents/**/*.agent.ts)
147
158
  await loadTenantsMiddlewares();
148
159
  await loadRoutes(); // load routes
149
160
  }
package/server/hono.ts CHANGED
@@ -11,8 +11,10 @@ import { Redis } from "ioredis"
11
11
  import { cfg } from "./config"
12
12
  import { rateLimit, createRedisStore, createMemoryStore, type RateLimitStore } from "./security"
13
13
  import { initializeRoutes } from "./routes";
14
+ import { patterns } from "../lib/endpoints";
14
15
  import { asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
15
16
  import { initializeApi } from "./api";
17
+ import { initializeAgents } from "./agents";
16
18
  import { initializeMcp } from "./mcp";
17
19
  import { getGlobalMiddlewares } from "../lib/middleware";
18
20
  import { jwt } from "../utils/func";
@@ -326,8 +328,8 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
326
328
  keyGenerator: keyByClientIp(''),
327
329
  }));
328
330
 
329
- // stricter limit for login endpoints
330
- app.use('/api/*/login', rateLimit({
331
+ // stricter limit for login endpoints — the login is a collection action
332
+ app.use(patterns.login, rateLimit({
331
333
  windowMs: cfg.server.rateLimit?.login?.windowMs ?? 60_000,
332
334
  max: cfg.server.rateLimit?.login?.max ?? 10,
333
335
  store,
@@ -399,7 +401,10 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
399
401
  // initialize api
400
402
  initializeApi(app);
401
403
 
402
- // initialize MCP (tools served at /mcp/:tenant_id)
404
+ // initialize the agents API (/api/:tenant_id/agents/:agent/:action)
405
+ initializeAgents(app);
406
+
407
+ // initialize MCP (tools served at /api/:tenant_id/mcp)
403
408
  initializeMcp(app);
404
409
 
405
410
 
package/server/mcp.ts CHANGED
@@ -10,72 +10,18 @@ import {
10
10
  ErrorCode,
11
11
  } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { StreamableHTTPTransport } from "@hono/mcp";
13
- import type Joi from "joi";
14
13
  import { cfg } from "./config";
15
14
  import { useRest } from "../database/rest";
15
+ import { createAgents } from "../lib/agents";
16
+ import { createApi } from "../lib/api";
16
17
  import { logger } from "../utils/logger";
17
18
  import type { McpTool, McpResource } from "../types/mcp";
18
19
  import type { HonoVariables } from "./env";
20
+ import { joiToJsonSchema } from "../lib/jsonSchema";
21
+ import { patterns } from "../lib/endpoints";
19
22
 
20
- // ─── Joi → JSON Schema (for the MCP protocol) ────────────────────────────
21
-
22
- function joiTypeToJson(type: string): string {
23
- switch (type) {
24
- case 'number': return 'number';
25
- case 'integer': return 'integer';
26
- case 'boolean': return 'boolean';
27
- case 'date': return 'string';
28
- default: return 'string';
29
- }
30
- }
31
-
32
- /** Minimal Joi → JSON Schema converter (covers the common field types). */
33
- function joiToJsonSchema(schema: Joi.Schema, describe = schema.describe() as any): any {
34
- const node: any = {};
35
- const type = describe?.type;
36
-
37
- switch (type) {
38
- case 'object': {
39
- node.type = 'object';
40
- const properties: Record<string, any> = {};
41
- const required: string[] = [];
42
- for (const [key, child] of Object.entries(describe.keys ?? {})) {
43
- properties[key] = joiToJsonSchema(child as any, child);
44
- if ((child as any)?.flags?.presence === 'required') required.push(key);
45
- }
46
- node.properties = properties;
47
- if (required.length) node.required = required;
48
- break;
49
- }
50
- case 'array': {
51
- node.type = 'array';
52
- if (describe.items?.length) node.items = joiToJsonSchema(describe.items[0], describe.items[0]);
53
- break;
54
- }
55
- default: {
56
- node.type = joiTypeToJson(type);
57
- if (type === 'date') node.format = 'date-time';
58
- if (type === 'string') {
59
- for (const rule of describe.rules ?? []) {
60
- if (['email', 'uri', 'uuid', 'isoDate', 'ip'].includes(rule.name)) {
61
- node.format = rule.name;
62
- }
63
- if (rule.name === 'pattern' && rule.args?.regex) {
64
- node.pattern = String(rule.args.regex).replace(/^\/|\/[gimsuy]*$/g, '');
65
- }
66
- }
67
- }
68
- if (describe.valids?.length && describe.valids.length <= 50) {
69
- node.enum = describe.valids;
70
- }
71
- break;
72
- }
73
- }
74
-
75
- if (describe?.flags?.presence === 'required') node.description = node.description ?? 'required';
76
- if (describe?.flags?.description) node.description = describe.flags.description;
77
- return node;
78
- }
23
+ // The Joi → JSON Schema converter is shared with the agent runtime — a tool
24
+ // must look identical whether it is exposed over MCP or to a model.
79
25
 
80
26
  // ─── URI template matching (users://{id} → params) ───────────────────────
81
27
 
@@ -148,7 +94,13 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
148
94
 
149
95
  const rest = new useRest({ tenant_id: tenantId });
150
96
  try {
151
- const result = await tool.exec({ c: undefined as any, rest, args: value });
97
+ const result = await tool.exec({
98
+ c: undefined as any,
99
+ rest,
100
+ args: value,
101
+ agents: createAgents(tenantId, rest),
102
+ api: createApi(rest),
103
+ });
152
104
  const isError = !!(result as any)?.isError;
153
105
  logger[isError ? 'warn' : 'info']('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: value, isError, duration: ms(start) });
154
106
  if (result && Array.isArray(result.content)) return result;
@@ -205,6 +157,8 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
205
157
  const result = await matched.resource.read({
206
158
  c: undefined as any,
207
159
  rest,
160
+ agents: createAgents(tenantId, rest),
161
+ api: createApi(rest),
208
162
  params: matched.params,
209
163
  uri,
210
164
  });
@@ -221,7 +175,7 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
221
175
 
222
176
  /**
223
177
  * Expose each tenant's MCP tools & resources over the Model Context Protocol
224
- * at `GET/POST /mcp/:tenant_id` (Streamable HTTP transport — works with
178
+ * at `GET/POST /api/:tenant_id/mcp` (Streamable HTTP transport — works with
225
179
  * Claude, Cursor, VS Code, and any MCP client).
226
180
  */
227
181
  export function initializeMcp(app: Hono<{ Variables: HonoVariables }>) {
@@ -238,7 +192,7 @@ export function initializeMcp(app: Hono<{ Variables: HonoVariables }>) {
238
192
  resourcesByTenant.set(resource._tenant_ ?? '', list);
239
193
  }
240
194
 
241
- app.all('/mcp/:tenant_id', async (c: Context<{ Variables: HonoVariables }>) => {
195
+ app.all(patterns.mcp, async (c: Context<{ Variables: HonoVariables }>) => {
242
196
  const tenantId = c.req.param('tenant_id') ?? '';
243
197
  const tools = toolsByTenant.get(tenantId) ?? [];
244
198
  const resources = resourcesByTenant.get(tenantId) ?? [];
package/server/routes.ts CHANGED
@@ -3,10 +3,26 @@ import { cfg } from "./config";
3
3
  import path from "path";
4
4
  import { jwt } from "../utils/func";
5
5
  import { useRest } from "../database/rest";
6
+ import { createAgents } from "../lib/agents";
7
+ import { createApi } from "../lib/api";
6
8
  import { io } from "./io";
7
9
  import { requestCtxStorage } from "../lib/asyncContextStorage";
8
10
  import type { HonoVariables } from "./env";
9
11
 
12
+ /** The context of a route handler — `rest` and `agents` share the same tenant. */
13
+ function routeContext(c: any, tenantId: string) {
14
+ const rest = new useRest({ tenant_id: tenantId });
15
+ return {
16
+ c,
17
+ rest,
18
+ // Tenant-scoped LLM registry, bound to this request's `rest`
19
+ agents: createAgents(tenantId, rest),
20
+ api: createApi(rest),
21
+ jwt: jwt,
22
+ io: io,
23
+ };
24
+ }
25
+
10
26
  function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
11
27
  for (let route of cfg.routes ?? []) {
12
28
  if (route._prefix_) {
@@ -24,42 +40,21 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
24
40
  if (route.method == "GET") {
25
41
  app.get(routePath, async (c) => { //
26
42
  resolveTenant();
27
- return route.handler({
28
- c,
29
- rest: new useRest({
30
- tenant_id: route._tenant_,
31
- }),
32
- jwt: jwt,
33
- io: io,
34
- });
43
+ return route.handler(routeContext(c, route._tenant_!));
35
44
  });
36
45
  }
37
46
 
38
47
  if (route.method == 'POST') {
39
48
  app.post(routePath, async (c) => {
40
49
  resolveTenant();
41
- return route.handler({
42
- c,
43
- rest: new useRest({
44
- tenant_id: route._tenant_,
45
- }),
46
- jwt: jwt,
47
- io: io,
48
- });
50
+ return route.handler(routeContext(c, route._tenant_!));
49
51
  });
50
52
  }
51
53
 
52
54
  if (route.method == "PUT") {
53
55
  app.put(routePath, async (c) => { //
54
56
  resolveTenant();
55
- return route.handler({
56
- c,
57
- rest: new useRest({
58
- tenant_id: route._tenant_,
59
- }),
60
- jwt: jwt,
61
- io: io,
62
- });
57
+ return route.handler(routeContext(c, route._tenant_!));
63
58
  });
64
59
  }
65
60
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The MCP **client** — an agent consuming a remote server's tools.
3
+ *
4
+ * The endpoint under test is the framework's own (`/api/:tenant/mcp`, served by
5
+ * `@hono/mcp`): the SDK client and our server are two independent implementations of
6
+ * the protocol, which is exactly what makes the round trip worth testing end to end —
7
+ * list, call, and back into the conversation as if the tool were local.
8
+ */
9
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
10
+ import { Agent, define, v } from "../index";
11
+ import { cfg, formatConfig } from "../server/config";
12
+ import { syncTenants } from "../database/tenant";
13
+ import { syncMcpTools } from "../lib/mcp";
14
+ import { closeMcpClients } from "../lib/mcpClient";
15
+ import { createApp } from "../server/hono";
16
+ import { at, fakeProvider, openaiText, openaiTool, request, type Fake } from "./fixtures/fake-provider";
17
+
18
+ const TENANT = "mcp-client";
19
+ const DIR = "packages/core/tests/fixtures/mcp-tenant";
20
+
21
+ let server: any;
22
+ let mcpUrl: string;
23
+ const servers: Fake[] = [];
24
+
25
+ const track = (fake: Fake): Fake => (servers.push(fake), fake);
26
+
27
+ beforeAll(async () => {
28
+ formatConfig({
29
+ server: { port: 4000 },
30
+ tenants: [{ id: TENANT, dir: DIR, database: { uri: "mongodb://localhost:27017/none" } }],
31
+ });
32
+ await syncTenants();
33
+ await syncMcpTools();
34
+
35
+ server = Bun.serve({ port: 0, fetch: createApp().fetch });
36
+ mcpUrl = `${server.url.href.replace(/\/$/, "")}/api/${TENANT}/mcp`;
37
+ });
38
+
39
+ afterAll(async () => {
40
+ for (const fake of servers) fake.stop();
41
+ await closeMcpClients();
42
+ try { server?.stop(true); } catch (_) {}
43
+ cfg.agents = [];
44
+ });
45
+
46
+ function makeAgent(provider: Fake, overrides: Record<string, any> = {}) {
47
+ return new Agent({
48
+ id: "test",
49
+ description: "A test agent.",
50
+ instructions: "You are a test agent.",
51
+ provider: { model: "test-model", apiKey: "test-key", baseUrl: provider.url, options: { retries: 0 } },
52
+ mcp: [mcpUrl],
53
+ ...overrides,
54
+ } as any);
55
+ }
56
+
57
+ describe("agent mcp — a remote server's tools", () => {
58
+ it("lists them as tools, calls one, and feeds the result back", async () => {
59
+ const provider = track(fakeProvider((_body, _req, hits) =>
60
+ hits === 1 ? openaiTool("echo", { msg: "hello mcp", times: 1 }) : openaiText("done")));
61
+ const agent = makeAgent(provider);
62
+
63
+ const result = await agent.generate("echo something");
64
+
65
+ // The remote tools are offered like any other
66
+ const names = request(provider, 0).body.tools.map((tool: any) => tool.function.name);
67
+ expect(names).toContain("echo");
68
+ expect(names).toContain("badge");
69
+
70
+ // …and the call really crossed the wire: the answer is the server's
71
+ const entry = at(result.toolResults, 0);
72
+ expect(entry.error).toBeUndefined();
73
+ expect(entry.result.content[0].text).toContain("hello mcp");
74
+ // What the model saw is the flattened text, not the protocol envelope
75
+ const toolMessage = result.messages.find((message) => message.role === "tool");
76
+ expect(toolMessage?.content).toContain("hello mcp");
77
+ expect(result.text).toBe("done");
78
+ });
79
+
80
+ it("lets a declared tool win over a remote one of the same name", async () => {
81
+ const provider = track(fakeProvider(() => openaiText("done")));
82
+ const local = define.Tool({
83
+ id: "echo",
84
+ description: "The tenant's own echo.",
85
+ inputSchema: v.object({}),
86
+ execute: async () => "local",
87
+ });
88
+ const agent = makeAgent(provider, { tools: { echo: local } });
89
+
90
+ await agent.generate("hi");
91
+
92
+ const offered = request(provider, 0).body.tools.filter((tool: any) => tool.function.name === "echo");
93
+ expect(offered).toHaveLength(1);
94
+ expect(offered[0].function.description).toBe("The tenant's own echo.");
95
+ });
96
+
97
+ it("contacts nothing with `tools: false`", async () => {
98
+ const provider = track(fakeProvider(() => openaiText("direct")));
99
+ const agent = makeAgent(provider);
100
+
101
+ await agent.generate("hi", { tools: false });
102
+
103
+ expect(request(provider, 0).body.tools).toBeUndefined();
104
+ });
105
+
106
+ it("runs anyway when the server is unreachable", async () => {
107
+ const provider = track(fakeProvider(() => openaiText("still here")));
108
+ const agent = makeAgent(provider, { mcp: ["http://127.0.0.1:9/mcp"] });
109
+
110
+ const result = await agent.generate("hi");
111
+
112
+ // A remote that is down costs its tools, not the run
113
+ expect(result.text).toBe("still here");
114
+ expect(request(provider, 0).body.tools).toBeUndefined();
115
+ }, 20_000);
116
+ });