Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/api/src/connections/built-in-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe('builtInMcps', () => {
test('finds the integration by host, as an address writes it', () => {
expect(builtInForHost('mcp.linear.app')?.integrationId).toBe('linear');
expect(builtInForHost('Search.Parallel.ai')?.integrationId).toBe('parallel');
expect(builtInForHost('api.keenable.ai')?.integrationId).toBe('keenable');
expect(builtInForHost('mcp.example.com')).toBeUndefined();
});
});
2 changes: 2 additions & 0 deletions packages/api/src/connections/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {EXA_INTEGRATION} from './exa/integration';
import {GOOGLE_INTEGRATION} from './google/integration';
import {GRANOLA_INTEGRATION} from './granola/integration';
import type {ApiIntegration} from './integration';
import {KEENABLE_INTEGRATION} from './keenable/integration';
import {LINEAR_INTEGRATION} from './linear/integration';
import {MYSQL_INTEGRATION} from './mysql/integration';
import {NOTION_INTEGRATION} from './notion/integration';
Expand All @@ -31,6 +32,7 @@ function modules(): Record<IntegrationId, ApiIntegration> {
cloudflare: CLOUDFLARE_INTEGRATION,
exa: EXA_INTEGRATION,
parallel: PARALLEL_INTEGRATION,
keenable: KEENABLE_INTEGRATION,
postgres: POSTGRES_INTEGRATION,
mysql: MYSQL_INTEGRATION,
mcp: CUSTOM_MCP_INTEGRATION,
Expand Down
238 changes: 238 additions & 0 deletions packages/api/src/connections/keenable/built-in.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import {beforeEach, describe, expect, mock, spyOn, test} from 'bun:test';
import {Ok, type Result} from 'ts-results-es';

import type {ApiError} from '@proxy/utils';

const findAccountConnection = mock();
const createConnection = mock();
const renewConnection = mock();
const updateConnectionName = mock();

const getConnectionWithCredential = mock();

mock.module('@proxy/db/connection', () => ({
getConnectionWithCredential,
findAccountConnection,
createConnection,
renewConnection,
shareConnection: mock(),
updateConnectionCredential: mock(),
getConnectionCredential: mock(async () => Ok(null)),
updateConnectionTools: mock(),
updateConnectionName,
}));

mock.module('@proxy/db/organization', () => ({getMembership: mock(async () => Ok({role: 'owner'})), listMemberNames: mock(async () => Ok([]))}));

const requireViewer = mock(async () => Ok({orgId: 'org-1', userId: 'user-1', slug: 'org-1', name: 'Workspace', role: 'owner', onboarded: true, experiments: []}));

mock.module('../../utils/user-org', () => ({requireViewer, requireOwner: requireViewer}));

mock.module('../../observability/log', () => ({
log: {debug: mock(), info: mock(), warn: mock(), error: mock()},
serializeError: mock(() => ({})),
}));

mock.module('../../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com'}}));

// The servers are on the internet.
mock.module('node:dns/promises', () => ({default: {lookup: async () => [{address: '93.184.216.34', family: 4}]}}));

// Readable stand-ins for encryption, so a test can see what was stored.
mock.module('../../utils/secret-crypto', () => ({
encryptSecret: (plaintext: string): Result<string, ApiError> => Ok(`enc:${plaintext}`),
decryptSecret: (stored: string): Result<string, ApiError> => Ok(stored.replace(/^enc:/, '')),
}));

// None of the integrations' modules is needed here: what they declare is the integrations' own.
mock.module('../integrations', () => ({apiIntegration: () => undefined, apiIntegrations: () => []}));

const CALLBACK = 'https://app.example.com/api/connections/keenable/callback';

const AUTH_BASE = 'https://caqmfcgrdovyjdhfnwzw.supabase.co/auth/v1';

const SERVER = 'https://api.keenable.ai/mcp-oauth?keenable_title=personal-agent-proxy';

// Keenable's server and its authorization server: each request, and its answers.
const keenable = {requests: [] as Array<{url: URL; body: string}>};

spyOn(globalThis, 'fetch').mockImplementation((async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
keenable.requests.push({url, body: typeof init?.body === 'string' ? init.body : ''});
if (url.href === `${AUTH_BASE}/oauth/clients/register`) {
return Response.json({client_id: 'client-1'}, {status: 201});
}
if (url.href === SERVER) {
// Keenable's server turns a bad key down at the door.
if (new Headers(init?.headers).get('authorization') === 'Bearer bad-key') {
return new Response('Unauthorized', {status: 401});
}
return Response.json({jsonrpc: '2.0', id: 1, result: {tools: [{name: 'search_web_pages', inputSchema: {type: 'object'}}]}});
}
return Response.json({access_token: 'key-1', token_type: 'bearer'});
}) as typeof fetch);

// The sign-in routes the api makes of Keenable's declaration.
async function routes() {
const {mcpConnectRoutes} = await import('../mcp/mcp-connect');
const {KEENABLE_MCP} = await import('./built-in');
return mcpConnectRoutes(KEENABLE_MCP);
}

function makeRes() {
const cookies: Array<{name: string; value: string; options: Record<string, unknown>}> = [];
const res = {
cookies,
redirect: mock(),
cookie: mock((name: string, value: string, options: Record<string, unknown>) => {
cookies.push({name, value, options});
}),
clearCookie: mock(),
status: mock(() => res),
json: mock(() => res),
};
return res;
}

const user = {userId: 'user-1', email: 'alex@example.com'};

async function start(query: Record<string, string> = {}) {
const {handleStart: handleStartKeenableRoute} = await routes();
const res = makeRes();
await handleStartKeenableRoute({user, params: {org: 'org-1'}, query, headers: {}} as never, res as never);
const cookie = res.cookies.find((candidate) => candidate.name === 'proxy.keenable-state');
const authorizeUrl = new URL(res.redirect.mock.calls[0]?.[0] as string);
return {cookie, authorizeUrl, state: authorizeUrl.searchParams.get('state') ?? ''};
}

async function callback(args: {cookie: string; query: Record<string, string>}) {
const {handleCallback: handleKeenableCallbackRoute} = await routes();
const res = makeRes();
await handleKeenableCallbackRoute({user, params: {org: 'org-1'}, query: args.query, headers: {cookie: `proxy.keenable-state=${args.cookie}`}} as never, res as never);
return res;
}

// What a connection made with a key answers, from what was saved.
async function connectWithKey(body: unknown) {
const {keyConnectRoute} = await import('../mcp/mcp-connect');
const {KEENABLE_MCP} = await import('./built-in');
return keyConnectRoute(KEENABLE_MCP)({user, body} as never);
}

const existing = {
id: 'conn-0',
integrationId: 'keenable',
orgId: 'org-1',
ownerUserId: 'user-1',
connectedById: 'user-1',
account: 'Signed in',
createdAt: new Date('2026-10-01T00:00:00Z'),
tools: null,
name: 'Team Keenable',
defaults: [{actionId: 'search'}],
credential: 'enc:{}',
};

beforeEach(() => {
mock.clearAllMocks();
keenable.requests = [];
findAccountConnection.mockResolvedValue(Ok(null));
createConnection.mockImplementation(async (data: {integrationId: string; account: string; defaults: string[]; name?: string}) =>
Ok({id: 'conn-1', integrationId: data.integrationId, account: data.account, createdAt: new Date(), tools: null, name: data.name ?? null, defaults: data.defaults.map((actionId) => ({actionId}))}),
);
getConnectionWithCredential.mockResolvedValue(Ok(null));
renewConnection.mockResolvedValue(Ok(undefined));
});

describe('handleStartKeenableRoute', () => {
test("registers with Keenable's authorization server and sends the browser to its sign-in with PKCE, for the server's address without its query", async () => {
const {cookie, authorizeUrl} = await start();

expect(keenable.requests[0]?.url.href).toBe(`${AUTH_BASE}/oauth/clients/register`);
expect(JSON.parse(keenable.requests[0]?.body ?? '{}')).toMatchObject({redirect_uris: [CALLBACK], token_endpoint_auth_method: 'none'});
expect(authorizeUrl.origin + authorizeUrl.pathname).toBe(`${AUTH_BASE}/oauth/authorize`);
expect(authorizeUrl.searchParams.get('client_id')).toBe('client-1');
expect(authorizeUrl.searchParams.get('scope')).toBe('email');
expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256');
expect(authorizeUrl.searchParams.get('resource')).toBe('https://api.keenable.ai/mcp-oauth');
expect(cookie?.options).toMatchObject({httpOnly: true, path: '/api/connections/keenable'});
});
});

describe('handleKeenableCallbackRoute', () => {
test('makes the connection, agents searching the web and reading pages', async () => {
const {cookie, state} = await start();
const res = await callback({cookie: cookie?.value ?? '', query: {state, code: 'code-1'}});

expect(keenable.requests.at(-1)?.url.href).toBe(`${AUTH_BASE}/oauth/token`);
const created = createConnection.mock.calls[0]?.[0];
expect(created).toMatchObject({orgId: 'org-1', integrationId: 'keenable', account: 'Signed in', defaults: ['search', 'fetch']});
expect(JSON.parse(String(created.credential).replace(/^enc:/, ''))).toMatchObject({clientId: 'client-1', refreshToken: null, expiresAt: null});
expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/org-1/connections/conn-1?added');
});

test('signing in from the catalog again makes another connection, since Keenable says nothing of who signed in', async () => {
const {cookie, state} = await start();
await callback({cookie: cookie?.value ?? '', query: {state, code: 'code-1'}});

expect(findAccountConnection).not.toHaveBeenCalled();
expect(createConnection).toHaveBeenCalledTimes(1);
});

test("begun from a connection's page (`?connection=`), the sign-in gives that connection the new tokens, its name kept", async () => {
getConnectionWithCredential.mockResolvedValue(Ok(existing));
const {cookie, state} = await start({connection: 'conn-0'});
const res = await callback({cookie: cookie?.value ?? '', query: {state, code: 'code-1'}});

expect(getConnectionWithCredential).toHaveBeenCalledWith(expect.objectContaining({orgId: 'org-1', userId: 'user-1'}), 'conn-0');
expect(createConnection).not.toHaveBeenCalled();
expect(renewConnection.mock.calls[0]?.[0]).toMatchObject({connectionId: 'conn-0', account: 'Signed in'});
expect(updateConnectionName).not.toHaveBeenCalled();
expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/org-1/connections/conn-0');
});

test('a sign-in turned down at Keenable goes back to the catalog', async () => {
const {cookie, state} = await start();
const res = await callback({cookie: cookie?.value ?? '', query: {state, error: 'access_denied'}});

expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/org-1/connections/new?error=keenable');
expect(createConnection).not.toHaveBeenCalled();
});
});

describe('keyConnectRoute', () => {
test('connects with an API key, checked by listing the tools with it: the key its credential, its end its account', async () => {
const connection = (await connectWithKey({key: 'keen_a1_1234'})).unwrap();

expect(keenable.requests.at(-1)?.url.href).toBe(SERVER);
expect(JSON.parse(keenable.requests.at(-1)?.body ?? '{}')).toMatchObject({method: 'tools/list'});
const created = createConnection.mock.calls[0]?.[0];
expect(created).toMatchObject({orgId: 'org-1', integrationId: 'keenable', account: 'API key ending 1234', defaults: ['search', 'fetch']});
expect(created.name).toBeUndefined();
expect(JSON.parse(String(created.credential).replace(/^enc:/, ''))).toEqual({auth: 'key', key: 'keen_a1_1234'});
expect(connection).toMatchObject({id: 'conn-1', name: null, unavailable: []});
});

test('a key Keenable turns down is refused, and nothing saved', async () => {
const error = (await connectWithKey({key: 'bad-key'})).unwrapErr();

expect(error.kind).toBe('credentials_rejected');
expect(createConnection).not.toHaveBeenCalled();
});

test("given a connection (`connection`), the key replaces that connection's credential, its name kept", async () => {
getConnectionWithCredential.mockResolvedValue(Ok(existing));
const connection = (await connectWithKey({connection: 'conn-0', key: 'keen_a2_5678'})).unwrap();

expect(createConnection).not.toHaveBeenCalled();
expect(renewConnection.mock.calls[0]?.[0]).toMatchObject({connectionId: 'conn-0', account: 'API key ending 5678', credential: `enc:${JSON.stringify({auth: 'key', key: 'keen_a2_5678'})}`});
expect(updateConnectionName).not.toHaveBeenCalled();
expect(connection).toMatchObject({id: 'conn-0', name: 'Team Keenable'});
});

test('a key alone makes a connection with no name of its own, and a key has no spaces', async () => {
const connection = (await connectWithKey({key: 'keen_a3_9999'})).unwrap();
expect(connection).toMatchObject({name: null, account: 'API key ending 9999'});
expect((await connectWithKey({key: 'Basic abc'})).unwrapErr().kind).toBe('validation_error');
});
});
21 changes: 21 additions & 0 deletions packages/api/src/connections/keenable/built-in.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {textParam, type BuiltInMcp} from '../mcp/built-in';
import {listMcpTools} from '../mcp/mcp-client';
import {KEENABLE} from './keenable-mcp';

/**
* Keenable, through its MCP server, as Exa and Parallel are, signed in to or given an API key; its
* keyless server is the same service. Keenable says nothing of who signed in, so the person names
* each connection, and an organization can have as many as it likes. A new Keenable connection lets
* agents search the web and read pages.
*/
export const KEENABLE_MCP: BuiltInMcp = {
integrationId: 'keenable',
server: KEENABLE,
// Keenable turns a bad key down at the door, so listing the tools with it is enough.
apiKey: {check: async (key) => (await listMcpTools(KEENABLE, key)).map(() => undefined)},
defaults: ['search', 'fetch'],
tools: {
search_web_pages: {actions: ['search'], run: (params) => ({collectionId: 'searches', operation: 'run', title: textParam(params, 'query')})},
fetch_page_content: {actions: ['fetch'], run: (params) => ({collectionId: 'pages', operation: 'read', title: textParam(params, 'url')})},
},
};
5 changes: 5 additions & 0 deletions packages/api/src/connections/keenable/integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {keenableConnectors} from '../../records/web-connector';
import {builtInMcpIntegration} from '../mcp/mcp-connect';
import {KEENABLE_MCP} from './built-in';

export const KEENABLE_INTEGRATION = builtInMcpIntegration(KEENABLE_MCP, keenableConnectors);
78 changes: 78 additions & 0 deletions packages/api/src/connections/keenable/keenable-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {describe, expect, test} from 'bun:test';

import {ApiErr} from '@proxy/utils';

import {formatKeenableResults, KEENABLE, parseKeenablePage} from './keenable-mcp';

describe('formatKeenableResults', () => {
test('writes the results out as Exa writes its own, a block a page, its snippet as the highlights', () => {
const text = JSON.stringify({
query: 'bun module mocks',
results: [
{
url: 'https://bun.sh/docs/test/mocks',
title: 'Mocks | Bun Docs',
description: '',
snippet: 'Mocking replaces a dependency.\nUse mock.module.\n',
published_at: '2026-10-12T08:00:00Z',
acquired_at: '2026-10-13T09:00:00Z',
},
{url: 'https://example.com', title: '', description: '', snippet: 'Example Domain', acquired_at: '2026-10-13T09:00:00Z'},
],
});
expect(formatKeenableResults(text).unwrap()).toBe(
[
'Title: Mocks | Bun Docs\nURL: https://bun.sh/docs/test/mocks\nPublished: 2026-10-12\nHighlights:\nMocking replaces a dependency.\nUse mock.module.',
'Title: https://example.com\nURL: https://example.com\nPublished: N/A\nHighlights:\nExample Domain',
].join('\n\n---\n\n'),
);
});

test('is Keenable out of reach when it answers with something else', () => {
expect(formatKeenableResults('Rate limit exceeded').unwrapErr().kind).toBe('provider_unreachable');
});
});

describe('parseKeenablePage', () => {
test('reads the title and the text', () => {
const text = JSON.stringify({url: 'https://example.com/', title: 'Example Domain', content: '# Example Domain\n\nThis domain is for examples.\n', description: ''});
expect(parseKeenablePage(text).unwrap()).toEqual({title: 'Example Domain', content: '# Example Domain\n\nThis domain is for examples.'});
});

test('a page without a title keeps all of it as the text', () => {
expect(parseKeenablePage(JSON.stringify({url: 'https://example.com/', content: 'Just text\n'})).unwrap()).toEqual({title: '', content: 'Just text'});
});
});

describe('KEENABLE.toolError', () => {
const toolError = (text: string) => KEENABLE.toolError?.(text);
const page = (url: string, reason: string) => toolError(`Error fetching ${url}: ${reason}`);

test('a page Keenable found nothing at is not found, with its address and what to do next, as Exa’s is', () => {
expect(page('https://example.com/docs/old-guide', 'The page does not exist at this URL. Check that the URL is correct.')).toMatchObject({
kind: 'not_found',
resource: 'page',
id: 'https://example.com/docs/old-guide',
message: expect.stringContaining('Keenable found no page at https://example.com/docs/old-guide'),
});
});

test('a site that turned its fetcher away for too many requests, or that it couldn’t reach, is out of reach for now', () => {
expect(page('https://example.com/busy', 'The target server rate-limited this fetch, please retry shortly.')).toMatchObject({
kind: 'provider_unreachable',
cause: {kind: 'http', status: 429},
message: expect.stringContaining('too many requests'),
});
expect(page('https://example.com/down', 'The target page could not be reached by the fetcher (upstream fetch failed). This may be transient — retrying is reasonable.')).toMatchObject({
kind: 'provider_unreachable',
message: 'Keenable couldn’t read https://example.com/down: The target page could not be reached by the fetcher (upstream fetch failed). This may be transient — retrying is reasonable.',
});
});

test('any other failure is in Keenable’s words', () => {
expect(page('https://example.com/members', 'The target server denied access to this URL')).toEqual(
ApiErr.validationError('Keenable turned it down: Error fetching https://example.com/members: The target server denied access to this URL'),
);
expect(toolError('Invalid arguments')).toEqual(ApiErr.validationError('Keenable turned it down: Invalid arguments'));
});
});
Loading