Skip to content

Commit 75774fd

Browse files
authored
feat(mcp): live MCP server provider — Streamable-HTTP client + createMcpProvider (#89)
Attach a remote MCP server as an IntegrationProvider so its tools flow through the same catalog/grants/approvals machinery as every other connector. McpHttpClient does the initialize handshake (session id), paginated tools/list, tools/call, JSON + SSE response framing; discovery reuses importMcpConnector so per-action risk comes from MCP annotations with fail-closed write default. New ./mcp subpath, 'mcp' provider kind. Bump 0.36.0.
1 parent 76d6fbc commit 75774fd

5 files changed

Lines changed: 435 additions & 1 deletion

File tree

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.35.0",
3+
"version": "0.36.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": {
@@ -83,6 +83,11 @@
8383
"types": "./dist/coverage-catalog.d.ts",
8484
"import": "./dist/coverage-catalog.js",
8585
"default": "./dist/coverage-catalog.js"
86+
},
87+
"./mcp": {
88+
"types": "./dist/mcp.d.ts",
89+
"import": "./dist/mcp.js",
90+
"default": "./dist/mcp.js"
8691
}
8792
},
8893
"files": [

src/__tests__/mcp.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { IntegrationError } from '../core-error.js'
3+
import { createMcpProvider, McpHttpClient } from '../mcp.js'
4+
5+
interface RecordedCall {
6+
method: string
7+
headers: Record<string, string>
8+
body: Record<string, unknown>
9+
}
10+
11+
/** Fake Streamable-HTTP MCP server with two tools, session ids, and
12+
* two-page tools/list pagination. */
13+
function fakeServer(opts: { sse?: boolean; failCall?: boolean } = {}) {
14+
const calls: RecordedCall[] = []
15+
const tools = [
16+
{
17+
name: 'search_issues',
18+
description: 'Search issues',
19+
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
20+
annotations: { readOnlyHint: true },
21+
},
22+
{
23+
name: 'delete_issue',
24+
description: 'Delete an issue',
25+
inputSchema: { type: 'object' },
26+
annotations: { destructiveHint: true },
27+
},
28+
]
29+
30+
const fetchImpl: typeof fetch = async (_url, init) => {
31+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
32+
calls.push({ method: body.method as string, headers: { ...(init?.headers as Record<string, string>) }, body })
33+
34+
const respond = (result: unknown) => {
35+
const message = { jsonrpc: '2.0', id: body.id, result }
36+
if (opts.sse) {
37+
return new Response(`event: message\ndata: ${JSON.stringify(message)}\n\n`, {
38+
headers: { 'content-type': 'text/event-stream', 'mcp-session-id': 'sess-1' },
39+
})
40+
}
41+
return new Response(JSON.stringify(message), {
42+
headers: { 'content-type': 'application/json', 'mcp-session-id': 'sess-1' },
43+
})
44+
}
45+
46+
switch (body.method) {
47+
case 'initialize':
48+
return respond({ protocolVersion: '2025-06-18', serverInfo: { name: 'fake', version: '1' } })
49+
case 'notifications/initialized':
50+
return new Response(null, { status: 202 })
51+
case 'tools/list': {
52+
const cursor = (body.params as { cursor?: string } | undefined)?.cursor
53+
return respond(cursor ? { tools: [tools[1]] } : { tools: [tools[0]], nextCursor: 'page2' })
54+
}
55+
case 'tools/call':
56+
if (opts.failCall) {
57+
return respond({ content: [{ type: 'text', text: 'boom' }], isError: true })
58+
}
59+
return respond({ content: [{ type: 'text', text: 'ok' }], structuredContent: { hits: 2 } })
60+
default:
61+
return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'nope' } }), {
62+
headers: { 'content-type': 'application/json' },
63+
})
64+
}
65+
}
66+
67+
return { fetchImpl, calls }
68+
}
69+
70+
describe('McpHttpClient', () => {
71+
it('initializes once, captures the session id, and paginates tools/list', async () => {
72+
const server = fakeServer()
73+
const client = new McpHttpClient({
74+
url: 'https://mcp.example.com/mcp',
75+
headers: { Authorization: 'Bearer tok' },
76+
fetchImpl: server.fetchImpl,
77+
})
78+
const tools = await client.listTools()
79+
expect(tools.map((t) => t.name)).toEqual(['search_issues', 'delete_issue'])
80+
81+
const methods = server.calls.map((c) => c.method)
82+
expect(methods).toEqual(['initialize', 'notifications/initialized', 'tools/list', 'tools/list'])
83+
// Session id from initialize rides on every subsequent request.
84+
expect(server.calls[2].headers['Mcp-Session-Id']).toBe('sess-1')
85+
expect(server.calls[2].headers.Authorization).toBe('Bearer tok')
86+
87+
// Second use does not re-initialize.
88+
await client.callTool('search_issues', { query: 'x' })
89+
expect(server.calls.filter((c) => c.method === 'initialize')).toHaveLength(1)
90+
})
91+
92+
it('parses SSE-framed responses', async () => {
93+
const server = fakeServer({ sse: true })
94+
const client = new McpHttpClient({ url: 'https://mcp.example.com/mcp', fetchImpl: server.fetchImpl })
95+
const tools = await client.listTools()
96+
expect(tools).toHaveLength(2)
97+
})
98+
99+
it('maps JSON-RPC errors to IntegrationError(provider_failure)', async () => {
100+
const server = fakeServer()
101+
const client = new McpHttpClient({ url: 'https://mcp.example.com/mcp', fetchImpl: server.fetchImpl })
102+
await client.initialize()
103+
await expect(
104+
(client as unknown as { request(m: string): Promise<unknown> }).request('bogus/method'),
105+
).rejects.toThrowError(IntegrationError)
106+
})
107+
})
108+
109+
describe('createMcpProvider', () => {
110+
it('discovers tools as a connector with annotation-derived risk + approvals', async () => {
111+
const server = fakeServer()
112+
const provider = await createMcpProvider({
113+
id: 'mcp:fake',
114+
server: { url: 'https://mcp.example.com/mcp', fetchImpl: server.fetchImpl },
115+
connectorId: 'fake',
116+
connectorTitle: 'Fake Tracker',
117+
})
118+
expect(provider.kind).toBe('mcp')
119+
const connectors = await provider.listConnectors()
120+
expect(connectors).toHaveLength(1)
121+
const byId = new Map(connectors[0].actions.map((a) => [a.id, a]))
122+
expect(byId.get('search_issues')?.risk).toBe('read')
123+
expect(byId.get('search_issues')?.approvalRequired).toBe(false)
124+
expect(byId.get('delete_issue')?.risk).toBe('destructive')
125+
expect(byId.get('delete_issue')?.approvalRequired).toBe(true)
126+
})
127+
128+
it('invokes tools/call and surfaces structured output', async () => {
129+
const server = fakeServer()
130+
const provider = await createMcpProvider({
131+
id: 'mcp:fake',
132+
server: { url: 'https://mcp.example.com/mcp', fetchImpl: server.fetchImpl },
133+
connectorId: 'fake',
134+
connectorTitle: 'Fake Tracker',
135+
})
136+
const result = await provider.invokeAction(
137+
connection('mcp:fake', 'fake'),
138+
{ connectionId: 'c1', action: 'search_issues', input: { query: 'x' } },
139+
)
140+
expect(result.ok).toBe(true)
141+
expect(result.output).toEqual({ hits: 2 })
142+
})
143+
144+
it('maps isError tool results to ok:false without throwing', async () => {
145+
const server = fakeServer({ failCall: true })
146+
const provider = await createMcpProvider({
147+
id: 'mcp:fake',
148+
server: { url: 'https://mcp.example.com/mcp', fetchImpl: server.fetchImpl },
149+
connectorId: 'fake',
150+
connectorTitle: 'Fake Tracker',
151+
})
152+
const result = await provider.invokeAction(
153+
connection('mcp:fake', 'fake'),
154+
{ connectionId: 'c1', action: 'search_issues', input: {} },
155+
)
156+
expect(result.ok).toBe(false)
157+
expect(result.metadata).toMatchObject({ mcp: true, isError: true })
158+
})
159+
})
160+
161+
function connection(providerId: string, connectorId: string) {
162+
return {
163+
id: 'c1',
164+
owner: { type: 'user' as const, id: 'u1' },
165+
providerId,
166+
connectorId,
167+
status: 'active' as const,
168+
grantedScopes: [],
169+
createdAt: '2026-01-01T00:00:00Z',
170+
updatedAt: '2026-01-01T00:00:00Z',
171+
}
172+
}

src/core-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type IntegrationProviderKind =
2222
| 'tangle_catalog'
2323
| 'executor'
2424
| 'custom'
25+
| 'mcp'
2526

2627
export type IntegrationConnectorCategory =
2728
| 'email'

0 commit comments

Comments
 (0)