Skip to content

Commit 7fef31c

Browse files
YujohnNattrassMastra CodeYJ
authored
feat(tool-provider): v1 ToolProvider runtime + server + client SDK — PR 2/3 of #17224 split (#17248)
Wires up the v1 ToolProvider runtime, server routes, `@mastra/client-js` resource, and editor integration so stored agents can pin OAuth-backed connections per toolkit and resolve them at run time. The Composio provider is rewritten on the new surface; Studio UI ships separately. A stored agent's config now carries a `toolProviders` block (`{ providerId: { connections, tools } }`) that the editor merges into the agent definition when it materializes. From the client SDK: ```ts const composio = client.toolProvider('composio'); const { items } = await composio.listConnections({ toolkit: 'gmail' }); ``` `assertModelAllowed` is intentionally dropped from `POST /stored/agents` and `PATCH /stored/agents/:id` as part of the surface-scoped model-policy plan from earlier — the picker-side enforcement is the source of truth and the save-path check was redundant. A follow-up commit (`eab90298d6`) also hardens the runtime against two soft failures that came out of review: a one-shot warn when caller-supplied connection scope falls back to the shared bucket, and capability mismatches now log-and-skip the offending toolkit instead of throwing and disabling the rest of the providers. Stacked on #17247; #17249 stacks on this. Replaces #17224. --------- Co-authored-by: Mastra Code <noreply@mastra.ai> Co-authored-by: YJ <yj@example.com>
1 parent 6855012 commit 7fef31c

33 files changed

Lines changed: 6773 additions & 258 deletions

.changeset/tp-runtime-hardening.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@mastra/server': patch
3+
'@mastra/client-js': patch
4+
---
5+
6+
Hardened v1 ToolProvider connection routes and SDK forwarding.
7+
8+
**Fail closed on unknown `connectionId`**
9+
10+
`DELETE /tool-providers/:providerId/connections/:connectionId` and
11+
`GET …/usage` now return `403` when storage is configured but no persisted
12+
row matches the supplied `connectionId` and the caller isn't an admin.
13+
Previously these routes fell through to the caller's own `authorId`, which
14+
let non-admin callers probe (and trigger provider-side `revokeConnection`
15+
for) IDs that didn't belong to them.
16+
17+
**Aligned authorize label validation with stored label rules**
18+
19+
`POST /tool-providers/:providerId/authorize` now enforces the same label
20+
rules the stored `toolProviders` config uses (`min(1)`, `max(32)`,
21+
`/^[A-Za-z0-9 _-]+$/`). Labels that pass `authorize` are now guaranteed to
22+
pass downstream stored-agent validation.
23+
24+
**SDK forwards `toolkit` on connection-scoped operations**
25+
26+
`@mastra/client-js`:
27+
28+
```ts
29+
await client.toolProviders.get('composio').disconnectConnection('ca_xxx', {
30+
toolkit: 'gmail',
31+
force: true,
32+
});
33+
34+
const usage = await client.toolProviders
35+
.get('composio')
36+
.getConnectionUsage('ca_xxx', { toolkit: 'gmail' });
37+
```
38+
39+
`disconnectConnection` now forwards `params.toolkit` (previously dropped)
40+
and `getConnectionUsage` accepts an optional `{ toolkit }` parameter so
41+
toolkit-scoped connection lookups disambiguate correctly server-side.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@mastra/server': patch
3+
---
4+
5+
Lazy-load `@mastra/core/tool-provider` inside the tool-provider handler so
6+
`@mastra/server` evaluates under any peer-compatible `@mastra/core` (peer floor
7+
remains `>=1.34.0-0`). The handler no longer imports `SHARED_BUCKET_ID` or
8+
`UnknownToolProviderError` at module load — `SHARED_BUCKET_ID` is mirrored as a
9+
local literal (verified in lockstep with core via a regression test), and
10+
`UnknownToolProviderError` is resolved via a cached `await import(...)` inside
11+
`resolveProvider` so the real class identity is preserved for `instanceof`.
12+
13+
OSS users running `Mastra` without a `MastraEditor` are unaffected: every
14+
tool-provider route still short-circuits with HTTP 500 "Editor is not
15+
configured" via `requireEditor(...)` before any core/tool-provider value is
16+
touched. Users with a `MastraEditor` already pull a compatible core
17+
transitively through `@mastra/editor`. Tool-provider routes require the new
18+
core exports at request time only — older cores surface a clear runtime error
19+
instead of crashing the server at boot.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@mastra/core': minor
3+
'@mastra/server': minor
4+
'@mastra/client-js': minor
5+
'@mastra/editor': minor
6+
---
7+
8+
Added the v1 ToolProvider runtime, server routes, client SDK methods, and editor wiring that power OAuth-backed integrations on stored agents.
9+
10+
**Stored agents can now pin OAuth connections per toolkit**
11+
12+
A stored agent's config accepts a new `toolProviders` shape that tells the runtime which connection to bind for each toolkit at execution time. Connections can be scoped per-author, shared across an org, or supplied by the caller.
13+
14+
```ts
15+
{
16+
toolProviders: {
17+
composio: {
18+
connections: {
19+
gmail: [{ kind: 'author', toolkit: 'gmail', connectionId: 'auth_abc', scope: 'per-author' }],
20+
},
21+
tools: {
22+
GMAIL_FETCH_EMAILS: { toolkit: 'gmail' },
23+
},
24+
},
25+
},
26+
}
27+
```
28+
29+
**New client SDK surface for managing connections**
30+
31+
```ts
32+
import { MastraClient } from '@mastra/client-js';
33+
34+
const client = new MastraClient({ baseUrl: '' });
35+
const composio = client.toolProvider('composio');
36+
37+
const { items } = await composio.listConnections({ toolkit: 'gmail' });
38+
await composio.disconnectConnection('auth_abc');
39+
```
40+
41+
**New `ToolProvider` interface for custom providers**
42+
43+
Providers implement a VNext surface (`listToolkitsVNext`, `listToolsVNext`, `resolveToolsVNext`) plus the auth round-trip (`authorize`, `getAuthStatus`, `listConnections`, `disconnectConnection`, `listConnectionFields`, `health`). The Composio provider has been rewritten on this surface; the older catalog methods remain as `@deprecated` shims for back-compat.
44+
45+
Connections list responses use `page`/`perPage` pagination, matching the rest of the server surface.
46+
47+
Both stored agents (`editor.agent.getById(...)`) and code-defined agents with stored overrides (`editor.agent.applyStoredOverrides(...)`) resolve `toolProviders` at request time, merging provider-resolved tools alongside code/registry/MCP/integration tools.
48+
49+
Stored agents that don't set `toolProviders` continue to work unchanged. The Studio/Builder UI ships separately.

.changeset/wild-ants-cross.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@mastra/core': patch
3+
'@mastra/editor': patch
4+
'@mastra/server': patch
5+
'@mastra/client-js': patch
6+
---
7+
8+
Improved observability and error isolation in the v1 ToolProvider runtime.
9+
10+
**Better visibility into connection-scope misconfiguration**
11+
12+
When an agent runs with a stored ToolProvider connection whose scope cannot be resolved from the request context, the runtime now logs a one-shot warning and falls back to a shared bucket instead of silently routing every caller to the same OAuth account. Multi-tenant deployments get a clear signal when their identity wiring isn't reaching the runtime.
13+
14+
**One bad toolkit no longer disables sibling providers**
15+
16+
If a provider returns more connections for a toolkit than its declared capabilities allow, the runtime now logs and skips that toolkit instead of throwing. Other providers and other toolkits on the same agent continue to resolve normally.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, expect, beforeEach, it, vi } from 'vitest';
2+
import { MastraClient } from '../client';
3+
4+
// Mock fetch globally
5+
global.fetch = vi.fn();
6+
7+
describe('ToolProvider Resource', () => {
8+
let client: MastraClient;
9+
const clientOptions = {
10+
baseUrl: 'http://localhost:4111',
11+
headers: {
12+
Authorization: 'Bearer test-key',
13+
'x-mastra-client-type': 'js',
14+
},
15+
};
16+
17+
const mockFetchResponse = (data: any) => {
18+
const response = new Response(undefined, {
19+
status: 200,
20+
statusText: 'OK',
21+
headers: new Headers({ 'Content-Type': 'application/json' }),
22+
});
23+
response.json = () => Promise.resolve(data);
24+
(global.fetch as any).mockResolvedValueOnce(response);
25+
};
26+
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
client = new MastraClient(clientOptions);
30+
});
31+
32+
it('listToolProviders hits the registry endpoint', async () => {
33+
const mockResponse = {
34+
providers: [
35+
{
36+
id: 'composio',
37+
displayName: 'Composio',
38+
capabilities: {
39+
multipleConnectionsPerToolkit: true,
40+
batchConnectionStatus: true,
41+
reauthorizeReusesConnectionId: true,
42+
},
43+
},
44+
],
45+
};
46+
mockFetchResponse(mockResponse);
47+
48+
const result = await client.listToolProviders();
49+
expect(result).toEqual(mockResponse);
50+
expect(global.fetch).toHaveBeenCalledWith(
51+
`${clientOptions.baseUrl}/api/tool-providers`,
52+
expect.objectContaining({
53+
headers: expect.objectContaining(clientOptions.headers),
54+
}),
55+
);
56+
});
57+
58+
describe('getToolProvider("composio")', () => {
59+
const providerId = 'composio';
60+
let provider: ReturnType<typeof client.getToolProvider>;
61+
62+
beforeEach(() => {
63+
provider = client.getToolProvider(providerId);
64+
});
65+
66+
it('listToolkits', async () => {
67+
const mockResponse = { data: [{ slug: 'gmail', name: 'Gmail' }] };
68+
mockFetchResponse(mockResponse);
69+
70+
const result = await provider.listToolkits();
71+
expect(result).toEqual(mockResponse);
72+
expect(global.fetch).toHaveBeenCalledWith(
73+
`${clientOptions.baseUrl}/api/tool-providers/composio/toolkits`,
74+
expect.objectContaining({
75+
headers: expect.objectContaining(clientOptions.headers),
76+
}),
77+
);
78+
});
79+
80+
it('listTools with no params', async () => {
81+
const mockResponse = { data: [], pagination: { page: 1, hasMore: false } };
82+
mockFetchResponse(mockResponse);
83+
84+
const result = await provider.listTools();
85+
expect(result).toEqual(mockResponse);
86+
expect(global.fetch).toHaveBeenCalledWith(
87+
`${clientOptions.baseUrl}/api/tool-providers/composio/tools`,
88+
expect.objectContaining({
89+
headers: expect.objectContaining(clientOptions.headers),
90+
}),
91+
);
92+
});
93+
94+
it('listTools with filters + pagination', async () => {
95+
const mockResponse = {
96+
data: [{ slug: 'gmail.fetch', name: 'Fetch', toolkit: 'gmail' }],
97+
pagination: { page: 2, perPage: 10, hasMore: true },
98+
};
99+
mockFetchResponse(mockResponse);
100+
101+
const result = await provider.listTools({
102+
toolkit: 'gmail',
103+
search: 'fetch',
104+
page: 2,
105+
perPage: 10,
106+
});
107+
expect(result).toEqual(mockResponse);
108+
109+
const callUrl = (global.fetch as any).mock.calls[0][0] as string;
110+
expect(callUrl).toContain(`${clientOptions.baseUrl}/api/tool-providers/composio/tools?`);
111+
expect(callUrl).toContain('toolkit=gmail');
112+
expect(callUrl).toContain('search=fetch');
113+
expect(callUrl).toContain('page=2');
114+
expect(callUrl).toContain('perPage=10');
115+
});
116+
117+
it('authorize POSTs the body and returns redirect + authId', async () => {
118+
const mockResponse = { url: 'https://oauth/redirect', authId: 'auth-123' };
119+
mockFetchResponse(mockResponse);
120+
121+
const body = { toolkit: 'gmail', connectionId: 'conn-1', toolName: 'gmail.fetch' };
122+
const result = await provider.authorize(body);
123+
expect(result).toEqual(mockResponse);
124+
expect(global.fetch).toHaveBeenCalledWith(
125+
`${clientOptions.baseUrl}/api/tool-providers/composio/authorize`,
126+
expect.objectContaining({
127+
method: 'POST',
128+
body: JSON.stringify(body),
129+
}),
130+
);
131+
});
132+
133+
it('getAuthStatus polls the auth-status endpoint', async () => {
134+
const mockResponse = { status: 'completed' };
135+
mockFetchResponse(mockResponse);
136+
137+
const result = await provider.getAuthStatus('auth-123');
138+
expect(result).toEqual(mockResponse);
139+
expect(global.fetch).toHaveBeenCalledWith(
140+
`${clientOptions.baseUrl}/api/tool-providers/composio/auth-status/auth-123`,
141+
expect.objectContaining({
142+
headers: expect.objectContaining(clientOptions.headers),
143+
}),
144+
);
145+
});
146+
147+
it('getConnectionStatus POSTs items', async () => {
148+
const mockResponse = {
149+
items: {
150+
'conn-1': { connected: true },
151+
'conn-2': { connected: false },
152+
},
153+
};
154+
mockFetchResponse(mockResponse);
155+
156+
const body = {
157+
items: [
158+
{ connectionId: 'conn-1', toolkit: 'gmail' },
159+
{ connectionId: 'conn-2', toolkit: 'gmail' },
160+
],
161+
};
162+
const result = await provider.getConnectionStatus(body);
163+
expect(result).toEqual(mockResponse);
164+
expect(global.fetch).toHaveBeenCalledWith(
165+
`${clientOptions.baseUrl}/api/tool-providers/composio/connection-status`,
166+
expect.objectContaining({
167+
method: 'POST',
168+
body: JSON.stringify(body),
169+
}),
170+
);
171+
});
172+
173+
it('getHealth hits the health endpoint', async () => {
174+
const mockResponse = { ok: true };
175+
mockFetchResponse(mockResponse);
176+
177+
const result = await provider.getHealth();
178+
expect(result).toEqual(mockResponse);
179+
expect(global.fetch).toHaveBeenCalledWith(
180+
`${clientOptions.baseUrl}/api/tool-providers/composio/health`,
181+
expect.objectContaining({
182+
headers: expect.objectContaining(clientOptions.headers),
183+
}),
184+
);
185+
});
186+
});
187+
});

0 commit comments

Comments
 (0)