diff --git a/.env.example b/.env.example index c0ee8587..646b1f67 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ GOOGLE_CONNECTOR_UID= # optional click-to-message shortcut in the workspace. LINQ_CONNECTOR= LINQ_PHONE_NUMBER= +# Optional You.com web search connection. Set to give the agent You.com MCP +# search tools; unset, the agent keeps its built-in search behavior. +YOU_API_KEY= # Development benchmarks only (pnpm bench:browser). BROWSER_BENCH_LABEL=self-hosted BROWSER_BENCH_REPETITIONS=1 diff --git a/README.md b/README.md index dca0550f..b5600591 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,23 @@ Gotchas: - Sending email and creating confirmed calendar events always require approval. Calendar events with attendees send Google invitations. +## You.com web search + +OpenInstinct can search the web through [You.com](https://you.com) by setting +one environment variable. With `YOU_API_KEY` set, the agent connects to the +You.com MCP server and gains its search tools for public research and +current facts; eve lists the connection and its tools automatically. Without +the key, nothing changes: the connection is omitted entirely and the agent +keeps its built-in search behavior. + +```bash +vercel env add YOU_API_KEY production --value --yes +``` + +Keys are available from the [You.com API dashboard](https://api.you.com). +The key is sent only as a bearer token to `https://api.you.com/mcp`; it never +enters conversation history or the model context. + ## Local development The **Deploy with Vercel** flow above is the simplest way to run OpenInstinct. It diff --git a/agent/connections/youcom.ts b/agent/connections/youcom.ts new file mode 100644 index 00000000..dac64cf5 --- /dev/null +++ b/agent/connections/youcom.ts @@ -0,0 +1,19 @@ +import { defineDynamic, defineMcpClientConnection } from "eve/connections"; +import { env } from "@shared/environment"; + +export default defineDynamic({ + events: { + "session.started": () => { + const apiKey = env.YOU_API_KEY; + return apiKey === undefined + ? null + : defineMcpClientConnection({ + auth: { getToken: async () => ({ token: apiKey }) }, + description: + "You.com web search and research: current information, facts, news, and primary sources. Use for public research, source discovery, comparisons, and verifying time-sensitive claims.", + instanceKey: "youcom", + url: "https://api.you.com/mcp", + }); + }, + }, +}); diff --git a/knip.config.ts b/knip.config.ts index 4ca503f8..74e92c07 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -3,6 +3,7 @@ import type { KnipConfig } from "knip"; export default { entry: [ "agent/channels/**/*.ts", + "agent/connections/**/*.ts", "agent/hooks/**/*.ts", "agent/instructions/**/*.ts", "agent/memory/**/*.ts", diff --git a/shared/environment/env.ts b/shared/environment/env.ts index 06f4f3ad..e36faaff 100644 --- a/shared/environment/env.ts +++ b/shared/environment/env.ts @@ -91,6 +91,9 @@ export const env = createEnv({ "LINQ_PHONE_NUMBER must use E.164 format" ) .optional(), + // Optional You.com web search connection for the agent. Set to expose the + // You.com MCP search tools; unset, the agent keeps its built-in behavior. + YOU_API_KEY: requiredValue.optional(), NODE_ENV: z .enum(["development", "production", "test"]) .default("production"), diff --git a/tests/agent/connections/youcom.test.ts b/tests/agent/connections/youcom.test.ts new file mode 100644 index 00000000..0480d5d8 --- /dev/null +++ b/tests/agent/connections/youcom.test.ts @@ -0,0 +1,99 @@ +import type { McpClientConnectionDefinition } from "eve/connections"; +import type { DynamicResolveContext } from "eve/tools"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const requiredEnvironment = { + BETTER_AUTH_SECRET: "test-auth-secret-0123456789abcdefghijklmnop", + BETTER_AUTH_URL: "https://example.com", + BLOB_READ_WRITE_TOKEN: "vercel_blob_rw_test", + DATABASE_URL: "postgresql://user:***@example.com/database", + KERNEL_API_KEY: "test-kernel-key", + SECRET_ENCRYPTION_KEY: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", +}; + +const resolveContext = { + model: null, + channel: { kind: "channel:linq", metadata: {} }, + messages: [], + session: { + auth: { + current: { + attributes: { workspaceId: "personal:workspace" }, + authenticator: "linq-message", + principalId: "user-1", + principalType: "user", + }, + initiator: null, + }, + id: "session-1", + }, +} satisfies DynamicResolveContext; + +describe("youcom connection", () => { + beforeEach(() => { + vi.resetModules(); + for (const [name, value] of Object.entries(requiredEnvironment)) { + vi.stubEnv(name, value); + } + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is omitted without a You.com API key", async () => { + vi.stubEnv("YOU_API_KEY", ""); + + const { default: youcom } = await import("@agent/connections/youcom"); + const resolve = youcom.events["session.started"]; + expect(resolve).toBeDefined(); + if (!resolve) return; + + expect(await resolve({}, resolveContext)).toBeNull(); + }); + + it("exposes the You.com MCP search connection with an API key", async () => { + vi.stubEnv("YOU_API_KEY", "test-youcom-key"); + + const { default: youcom } = await import("@agent/connections/youcom"); + const resolve = youcom.events["session.started"]; + expect(resolve).toBeDefined(); + if (!resolve) return; + + const resolved = await resolve({}, resolveContext); + // SAFETY: The resolver returns a single connection definition, so the + // dynamic result is the MCP connection definition this module builds. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- This test asserts the exact connection definition the youcom resolver builds. + const connection = resolved as McpClientConnectionDefinition; + expect(connection).toMatchObject({ + instanceKey: "youcom", + url: "https://api.you.com/mcp", + }); + expect(connection.description).toContain("You.com web search"); + }); + + it("sends the configured API key as the bearer token", async () => { + vi.stubEnv("YOU_API_KEY", "test-youcom-key"); + + const { default: youcom } = await import("@agent/connections/youcom"); + const resolve = youcom.events["session.started"]; + expect(resolve).toBeDefined(); + if (!resolve) return; + + const resolved = await resolve({}, resolveContext); + // SAFETY: The resolver returns a single connection definition, so the + // dynamic result is the MCP connection definition this module builds. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- This test asserts the exact connection definition the youcom resolver builds. + const connection = resolved as McpClientConnectionDefinition; + const auth = connection.auth; + expect(auth).toBeDefined(); + if (!auth || !("getToken" in auth)) return; + + await expect( + auth.getToken({ + connection: { url: "https://api.you.com/mcp" }, + principal: { type: "app" }, + }) + ).resolves.toEqual({ token: "test-youcom-key" }); + }); +});