Skip to content

Commit 76d6fbc

Browse files
authored
feat(delegated-tools): reusable external-agent tool-callback bridge (#86)
Lift the delegated-tool-callback pattern into a vendor-neutral primitive: a product mints a scoped, TTL'd, tool-allow-listed bearer, packages it as a lease, and hands it to an external stateful agent (voice caller, autonomous worker) that calls back into the product's tools mid-session over JSON-RPC 2.0. - mintDelegatedToolToken / verifyDelegatedToolToken: signed-claims bearer (workspaceId + allowedTools + expiresAt), WebCrypto HMAC-SHA256, configurable prefix (default dtt_), fail-closed when no secret. - handleDelegatedToolCall / handleDelegatedToolRequest: transport-agnostic JSON-RPC handler (initialize / tools/list / tools/call) with fail-closed gates — token fresh, tool allow-listed, tool resolves, integration connected — all via injected seams (verifyToken, resolveTool, isIntegrationConnected). No voice/connector specifics baked in. - issueDelegatedToolLease: standardized lease shape (token, allowedTools, expiresAt, callbackUrl). Additive ./delegated-tools subpath (exports + tsup entry). 26 tests covering mint/verify/expiry/forge-rejection, JSON-RPC envelope, each fail-closed gate, and HTTP transport. Bump 0.34.0 -> 0.35.0.
1 parent 91c0dec commit 76d6fbc

8 files changed

Lines changed: 789 additions & 1 deletion

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ OAuth credentials.
165165
| `runIntegrationHealthchecks` | Checks connection status, registry executability, scope shape, and optional live provider tests. |
166166
| `receiveIntegrationWebhook` | Verifies inbound webhooks, dedupes provider events, and dispatches normalized trigger events. |
167167
| `buildIntegrationBridgeEnvironment` | Encodes scoped sandbox capabilities for sandbox processes or executor-style CLIs. |
168+
| `mintDelegatedToolToken` / `verifyDelegatedToolToken` | Signed-claims bearer (workspace + tool allow-list + TTL) for an external agent that calls back into the product's tools mid-session. |
169+
| `issueDelegatedToolLease` | Standardized lease (token + allow-list + expiry + callback URL) the product hands to its external agent. |
170+
| `handleDelegatedToolCall` / `handleDelegatedToolRequest` | Transport-agnostic JSON-RPC 2.0 callback handler with fail-closed gates (token fresh, tool allow-listed, integration connected) via product-supplied seams. |
168171
| `createTangleIntegrationsClient` | Tiny generated-app/sandbox client for platform `/v1/integrations/invoke`. |
169172
| `inferIntegrationManifestFromTools` / `validateIntegrationManifest` | Deterministic manifest helpers for Builder and platform APIs. |
170173
| `renderConsentSummary` / `renderApprovalCopy` | User-facing consent and approval copy from manifests/actions. |

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tangle-network/agent-integrations",
3-
"version": "0.34.0",
3+
"version": "0.35.0",
44
"description": "Vendor-neutral integration contracts and runtime helpers for sandbox and agent apps.",
55
"homepage": "https://github.com/tangle-network/agent-integrations#readme",
66
"repository": {
@@ -54,6 +54,11 @@
5454
"import": "./dist/webhooks/index.js",
5555
"default": "./dist/webhooks/index.js"
5656
},
57+
"./delegated-tools": {
58+
"types": "./dist/delegated-tools/index.d.ts",
59+
"import": "./dist/delegated-tools/index.js",
60+
"default": "./dist/delegated-tools/index.js"
61+
},
5762
"./registry": {
5863
"types": "./dist/registry.d.ts",
5964
"import": "./dist/registry.js",
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
mintDelegatedToolToken,
4+
verifyDelegatedToolToken,
5+
} from '../token.js'
6+
import { issueDelegatedToolLease } from '../lease.js'
7+
import {
8+
handleDelegatedToolCall,
9+
handleDelegatedToolRequest,
10+
DelegatedToolInvocationError,
11+
type DelegatedToolCallSeams,
12+
type ResolvedDelegatedTool,
13+
} from '../handler.js'
14+
15+
const SECRET = 'test-delegated-secret'
16+
const WORKSPACE = 'ws_123'
17+
18+
describe('mint/verify token', () => {
19+
it('round-trips claims', async () => {
20+
const token = await mintDelegatedToolToken({
21+
workspaceId: WORKSPACE,
22+
allowedTools: ['gcal__events.create', 'slack__post'],
23+
ttlSeconds: 600,
24+
secret: SECRET,
25+
})
26+
expect(token).toBeDefined()
27+
expect(token!.startsWith('dtt_')).toBe(true)
28+
29+
const claims = await verifyDelegatedToolToken(token!, { secret: SECRET })
30+
expect(claims).not.toBeNull()
31+
expect(claims!.workspaceId).toBe(WORKSPACE)
32+
expect(claims!.allowedTools).toEqual(['gcal__events.create', 'slack__post'])
33+
expect(claims!.expiresAt).toBeGreaterThan(Date.now())
34+
})
35+
36+
it('honors a custom prefix', async () => {
37+
const token = await mintDelegatedToolToken({
38+
workspaceId: WORKSPACE,
39+
allowedTools: ['a'],
40+
ttlSeconds: 60,
41+
secret: SECRET,
42+
prefix: 'gtmmcp_',
43+
})
44+
expect(token!.startsWith('gtmmcp_')).toBe(true)
45+
// default-prefix verify rejects a custom-prefix token
46+
expect(await verifyDelegatedToolToken(token!, { secret: SECRET })).toBeNull()
47+
expect(await verifyDelegatedToolToken(token!, { secret: SECRET, prefix: 'gtmmcp_' })).not.toBeNull()
48+
})
49+
50+
it('fail-closed: no secret ⇒ no token', async () => {
51+
expect(await mintDelegatedToolToken({ workspaceId: WORKSPACE, allowedTools: [], ttlSeconds: 60 })).toBeUndefined()
52+
expect(await mintDelegatedToolToken({ workspaceId: WORKSPACE, allowedTools: [], ttlSeconds: 60, secret: ' ' })).toBeUndefined()
53+
})
54+
55+
it('rejects an expired token', async () => {
56+
const t0 = 1_000_000_000_000
57+
const token = await mintDelegatedToolToken({
58+
workspaceId: WORKSPACE,
59+
allowedTools: ['x'],
60+
ttlSeconds: 60,
61+
secret: SECRET,
62+
now: t0,
63+
})
64+
expect(await verifyDelegatedToolToken(token!, { secret: SECRET, now: t0 + 59_000 })).not.toBeNull()
65+
expect(await verifyDelegatedToolToken(token!, { secret: SECRET, now: t0 + 60_001 })).toBeNull()
66+
})
67+
68+
it('rejects a forged signature', async () => {
69+
const token = await mintDelegatedToolToken({
70+
workspaceId: WORKSPACE,
71+
allowedTools: ['x'],
72+
ttlSeconds: 60,
73+
secret: SECRET,
74+
})
75+
// tamper with the signature segment
76+
const tampered = token!.slice(0, -2) + (token!.endsWith('AA') ? 'BB' : 'AA')
77+
expect(await verifyDelegatedToolToken(tampered, { secret: SECRET })).toBeNull()
78+
// wrong secret rejects
79+
expect(await verifyDelegatedToolToken(token!, { secret: 'other-secret' })).toBeNull()
80+
})
81+
82+
it('rejects a tampered claims payload (privilege escalation attempt)', async () => {
83+
const token = await mintDelegatedToolToken({
84+
workspaceId: WORKSPACE,
85+
allowedTools: ['read'],
86+
ttlSeconds: 60,
87+
secret: SECRET,
88+
})
89+
const [, sig] = token!.slice('dtt_'.length).split('.')
90+
const forgedClaims = Buffer.from(
91+
JSON.stringify({ workspaceId: WORKSPACE, allowedTools: ['admin.delete'], expiresAt: Date.now() + 60_000 }),
92+
)
93+
.toString('base64url')
94+
const forged = `dtt_${forgedClaims}.${sig}`
95+
expect(await verifyDelegatedToolToken(forged, { secret: SECRET })).toBeNull()
96+
})
97+
98+
it('rejects malformed tokens and wrong prefix', async () => {
99+
expect(await verifyDelegatedToolToken('not-a-token', { secret: SECRET })).toBeNull()
100+
expect(await verifyDelegatedToolToken('dtt_only-one-segment', { secret: SECRET })).toBeNull()
101+
expect(await verifyDelegatedToolToken('dtt_.sig', { secret: SECRET })).toBeNull()
102+
expect(await verifyDelegatedToolToken('foo_abc.def', { secret: SECRET })).toBeNull()
103+
})
104+
})
105+
106+
describe('issueDelegatedToolLease', () => {
107+
it('packages token + allow-list + expiry + callbackUrl', async () => {
108+
const t0 = 2_000_000_000_000
109+
const lease = await issueDelegatedToolLease({
110+
workspaceId: WORKSPACE,
111+
allowedTools: ['gcal__events.create'],
112+
ttlSeconds: 300,
113+
secret: SECRET,
114+
callbackUrl: 'https://app.example/api/delegated/mcp',
115+
now: t0,
116+
})
117+
expect(lease).not.toBeNull()
118+
expect(lease!.allowedTools).toEqual(['gcal__events.create'])
119+
expect(lease!.expiresAt).toBe(t0 + 300_000)
120+
expect(lease!.callbackUrl).toBe('https://app.example/api/delegated/mcp')
121+
const claims = await verifyDelegatedToolToken(lease!.token, { secret: SECRET, now: t0 })
122+
expect(claims!.workspaceId).toBe(WORKSPACE)
123+
})
124+
125+
it('fail-closed: no secret ⇒ null lease', async () => {
126+
expect(
127+
await issueDelegatedToolLease({ workspaceId: WORKSPACE, allowedTools: ['x'], ttlSeconds: 60 }),
128+
).toBeNull()
129+
})
130+
})
131+
132+
function tool(name: string, invoke?: ResolvedDelegatedTool['invoke']): ResolvedDelegatedTool {
133+
return {
134+
name,
135+
description: `desc ${name}`,
136+
invoke: invoke ?? (async (args) => ({ ok: true, echo: args })),
137+
}
138+
}
139+
140+
function seams(overrides: Partial<DelegatedToolCallSeams> = {}): DelegatedToolCallSeams {
141+
return {
142+
verifyToken: async (bearer) => verifyDelegatedToolToken(bearer, { secret: SECRET }),
143+
resolveTool: async (_ws, name) => (name === 'gcal__create' ? tool(name) : null),
144+
isIntegrationConnected: async () => true,
145+
...overrides,
146+
}
147+
}
148+
149+
async function bearer(allowedTools: string[]): Promise<string> {
150+
const token = await mintDelegatedToolToken({ workspaceId: WORKSPACE, allowedTools, ttlSeconds: 600, secret: SECRET })
151+
return `Bearer ${token}`
152+
}
153+
154+
describe('handleDelegatedToolCall — JSON-RPC envelope', () => {
155+
it('initialize returns protocol + serverInfo', async () => {
156+
const res = await handleDelegatedToolCall(
157+
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
158+
await bearer(['gcal__create']),
159+
seams({ serverInfo: { name: 'creative', version: '9' } }),
160+
)
161+
expect(res.id).toBe(1)
162+
expect((res.result as any).serverInfo).toEqual({ name: 'creative', version: '9' })
163+
expect((res.result as any).protocolVersion).toBeDefined()
164+
})
165+
166+
it('tools/list returns allow-list ∩ resolvable ∩ connected', async () => {
167+
const res = await handleDelegatedToolCall(
168+
{ id: 2, method: 'tools/list' },
169+
await bearer(['gcal__create', 'unknown__tool']),
170+
seams(),
171+
)
172+
const tools = (res.result as any).tools
173+
expect(tools).toHaveLength(1)
174+
expect(tools[0].name).toBe('gcal__create')
175+
expect(tools[0].inputSchema).toEqual({ type: 'object' })
176+
})
177+
178+
it('tools/list omits disconnected tools', async () => {
179+
const res = await handleDelegatedToolCall(
180+
{ id: 3, method: 'tools/list' },
181+
await bearer(['gcal__create']),
182+
seams({ isIntegrationConnected: async () => false }),
183+
)
184+
expect((res.result as any).tools).toHaveLength(0)
185+
})
186+
187+
it('tools/call happy path invokes and returns result', async () => {
188+
const res = await handleDelegatedToolCall(
189+
{ id: 4, method: 'tools/call', params: { name: 'gcal__create', arguments: { title: 'Sync' } } },
190+
await bearer(['gcal__create']),
191+
seams(),
192+
)
193+
expect(res.error).toBeUndefined()
194+
expect(res.result).toEqual({ ok: true, echo: { title: 'Sync' } })
195+
})
196+
197+
it('unknown method ⇒ -32601', async () => {
198+
const res = await handleDelegatedToolCall(
199+
{ id: 5, method: 'frobnicate' },
200+
await bearer(['gcal__create']),
201+
seams(),
202+
)
203+
expect(res.error?.code).toBe(-32601)
204+
})
205+
})
206+
207+
describe('handleDelegatedToolCall — fail-closed gates', () => {
208+
it('gate 1: missing/invalid bearer ⇒ -32001', async () => {
209+
const res = await handleDelegatedToolCall({ id: 1, method: 'tools/list' }, undefined, seams())
210+
expect(res.error?.code).toBe(-32001)
211+
const res2 = await handleDelegatedToolCall({ id: 1, method: 'tools/list' }, 'Bearer dtt_bogus.sig', seams())
212+
expect(res2.error?.code).toBe(-32001)
213+
})
214+
215+
it('gate 2: tool not in lease allow-list ⇒ -32602', async () => {
216+
const res = await handleDelegatedToolCall(
217+
{ id: 2, method: 'tools/call', params: { name: 'gcal__create' } },
218+
await bearer(['slack__post']),
219+
seams(),
220+
)
221+
expect(res.error?.code).toBe(-32602)
222+
expect(res.error?.message).toContain('not delegated')
223+
})
224+
225+
it('gate 3: tool does not resolve for workspace ⇒ -32602', async () => {
226+
const res = await handleDelegatedToolCall(
227+
{ id: 3, method: 'tools/call', params: { name: 'ghost__tool' } },
228+
await bearer(['ghost__tool']),
229+
seams(),
230+
)
231+
expect(res.error?.code).toBe(-32602)
232+
expect(res.error?.message).toContain('not available')
233+
})
234+
235+
it('gate 4: integration disconnected ⇒ -32602, invoke never runs', async () => {
236+
let invoked = false
237+
const res = await handleDelegatedToolCall(
238+
{ id: 4, method: 'tools/call', params: { name: 'gcal__create' } },
239+
await bearer(['gcal__create']),
240+
seams({
241+
resolveTool: async (_ws, name) => tool(name, async () => { invoked = true; return {} }),
242+
isIntegrationConnected: async () => false,
243+
}),
244+
)
245+
expect(res.error?.code).toBe(-32602)
246+
expect(res.error?.message).toContain('not connected')
247+
expect(invoked).toBe(false)
248+
})
249+
250+
it('expired bearer is rejected at the verify gate', async () => {
251+
const t0 = 1_000_000_000_000
252+
const token = await mintDelegatedToolToken({ workspaceId: WORKSPACE, allowedTools: ['gcal__create'], ttlSeconds: 60, secret: SECRET, now: t0 })
253+
const res = await handleDelegatedToolCall(
254+
{ id: 5, method: 'tools/call', params: { name: 'gcal__create' } },
255+
`Bearer ${token}`,
256+
seams({ verifyToken: async (b) => verifyDelegatedToolToken(b, { secret: SECRET, now: t0 + 120_000 }) }),
257+
)
258+
expect(res.error?.code).toBe(-32001)
259+
})
260+
})
261+
262+
describe('handleDelegatedToolCall — invocation errors', () => {
263+
it('DelegatedToolInvocationError surfaces its code + data', async () => {
264+
const res = await handleDelegatedToolCall(
265+
{ id: 1, method: 'tools/call', params: { name: 'gcal__create' } },
266+
await bearer(['gcal__create']),
267+
seams({
268+
resolveTool: async (_ws, name) =>
269+
tool(name, async () => {
270+
throw new DelegatedToolInvocationError('hub 502', { code: -32050, data: { status: 502 } })
271+
}),
272+
}),
273+
)
274+
expect(res.error?.code).toBe(-32050)
275+
expect(res.error?.data).toEqual({ status: 502 })
276+
})
277+
278+
it('arbitrary throw ⇒ -32000', async () => {
279+
const res = await handleDelegatedToolCall(
280+
{ id: 2, method: 'tools/call', params: { name: 'gcal__create' } },
281+
await bearer(['gcal__create']),
282+
seams({ resolveTool: async (_ws, name) => tool(name, async () => { throw new Error('boom') }) }),
283+
)
284+
expect(res.error?.code).toBe(-32000)
285+
expect(res.error?.message).toBe('boom')
286+
})
287+
})
288+
289+
describe('handleDelegatedToolRequest — HTTP transport', () => {
290+
function req(bodyObj: unknown, headers: Record<string, string> = {}, method = 'POST'): Request {
291+
return new Request('https://app.example/api/delegated/mcp', {
292+
method,
293+
headers: { 'content-type': 'application/json', ...headers },
294+
body: method === 'POST' ? JSON.stringify(bodyObj) : undefined,
295+
})
296+
}
297+
298+
it('non-POST ⇒ 405', async () => {
299+
const res = await handleDelegatedToolRequest(req({}, {}, 'GET'), seams())
300+
expect(res.status).toBe(405)
301+
})
302+
303+
it('bad JSON ⇒ parse error -32700', async () => {
304+
const request = new Request('https://app.example/x', { method: 'POST', body: '{not json' })
305+
const res = await handleDelegatedToolRequest(request, seams())
306+
const json = (await res.json()) as any
307+
expect(json.error.code).toBe(-32700)
308+
})
309+
310+
it('unauthorized ⇒ 401 status', async () => {
311+
const res = await handleDelegatedToolRequest(req({ id: 1, method: 'tools/list' }), seams())
312+
expect(res.status).toBe(401)
313+
})
314+
315+
it('authorized call ⇒ 200 with result', async () => {
316+
const res = await handleDelegatedToolRequest(
317+
req(
318+
{ id: 1, method: 'tools/call', params: { name: 'gcal__create', arguments: { a: 1 } } },
319+
{ authorization: await bearer(['gcal__create']) },
320+
),
321+
seams(),
322+
)
323+
expect(res.status).toBe(200)
324+
const json = (await res.json()) as any
325+
expect(json.result).toEqual({ ok: true, echo: { a: 1 } })
326+
})
327+
328+
it('authenticated JSON-RPC error rides a 200', async () => {
329+
const res = await handleDelegatedToolRequest(
330+
req({ id: 1, method: 'tools/call', params: { name: 'nope' } }, { authorization: await bearer(['gcal__create']) }),
331+
seams(),
332+
)
333+
expect(res.status).toBe(200)
334+
const json = (await res.json()) as any
335+
expect(json.error.code).toBe(-32602)
336+
})
337+
})

0 commit comments

Comments
 (0)