From 65c2d8a686732008c5e338454437a6489290856a Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Tue, 20 Oct 2026 19:25:55 +0200 Subject: [PATCH] add notra as a built-in integration - notra's posts through its MCP server (mcp.usenotra.com), signed in to with OAuth or given an API key, declared once in BUILT_IN_MCP - one posts collection: list newest first and narrowed by type, open, create as a draft, edit title, slug and content, delete - publish and unpublish are commands behind their own high-risk Publish posts action, since a published post reaches the workspace's hosted site - the account is the workspace, from whoami, so a key for a connected workspace renews that connection - notra keeps a session and checks a key only when a tool is called, so the server does the handshake and the key check asks whoami - docs page, nav and index entry, and the logo --- packages/api/src/connections/built-in-mcp.ts | 3 +- .../api/src/connections/notra/notra-mcp.ts | 100 +++++++++++++ packages/api/src/connections/notra/route.ts | 31 ++++ packages/api/src/mcp/route.test.ts | 2 +- packages/api/src/records/connectors.ts | 2 + packages/api/src/records/notra-connector.ts | 138 ++++++++++++++++++ packages/docs/content/connections/notra.mdx | 47 ++++++ packages/docs/content/index.mdx | 3 +- packages/docs/src/nav.ts | 1 + packages/integrations/src/catalog.ts | 113 ++++++++++++++ packages/integrations/src/index.ts | 2 + packages/integrations/src/types.ts | 1 + packages/web/public/integrations/notra.svg | 7 + packages/web/src/lib/integrations.ts | 3 +- 14 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/connections/notra/notra-mcp.ts create mode 100644 packages/api/src/connections/notra/route.ts create mode 100644 packages/api/src/records/notra-connector.ts create mode 100644 packages/docs/content/connections/notra.mdx create mode 100644 packages/web/public/integrations/notra.svg diff --git a/packages/api/src/connections/built-in-mcp.ts b/packages/api/src/connections/built-in-mcp.ts index 92c23885..7739a6ca 100644 --- a/packages/api/src/connections/built-in-mcp.ts +++ b/packages/api/src/connections/built-in-mcp.ts @@ -3,6 +3,7 @@ import {EXA_MCP} from './exa/route'; import {GRANOLA_MCP} from './granola/route'; import {LINEAR_MCP} from './linear/route'; import {NOTION_MCP} from './notion/route'; +import {NOTRA_MCP} from './notra/route'; import {PARALLEL_MCP} from './parallel/route'; /** @@ -11,7 +12,7 @@ import {PARALLEL_MCP} from './parallel/route'; * its server's address, and moves custom connections to its server over when the api starts. Its * catalog entry says it is signed in to (`signIn`), for the web app. */ -export const BUILT_IN_MCP: BuiltInMcp[] = [GRANOLA_MCP, NOTION_MCP, LINEAR_MCP, EXA_MCP, PARALLEL_MCP]; +export const BUILT_IN_MCP: BuiltInMcp[] = [GRANOLA_MCP, NOTION_MCP, LINEAR_MCP, EXA_MCP, PARALLEL_MCP, NOTRA_MCP]; /** The integration that takes over a custom MCP server at this host, if one does. */ export function builtInForHost(host: string): BuiltInMcp | undefined { diff --git a/packages/api/src/connections/notra/notra-mcp.ts b/packages/api/src/connections/notra/notra-mcp.ts new file mode 100644 index 00000000..dda52042 --- /dev/null +++ b/packages/api/src/connections/notra/notra-mcp.ts @@ -0,0 +1,100 @@ +import {Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; + +import {callMcpTool} from '../mcp/mcp-client'; +import type {McpServer} from '../mcp/mcp-oauth'; + +/** + * Notra's MCP server and what its tools answer: JSON, as Notra's API does. A sign-in, or an API + * key, is for one workspace. The server keeps a session between calls, and checks an API key only + * when a tool is called, answering a bad one with the tool failing: "Invalid API key". A tool that + * fails otherwise says why in words: "Post not found: …", or what the API turned down. + */ + +const AUTH_BASE = 'https://oauth.usenotra.com/oauth2'; + +// The API out of reach behind the server, rather than the call turned down. +const UNREACHABLE = /^HTTP 5\d\d|timed out|was cancelled|fetch failed/i; + +function notraToolError(text: string): ApiError | null { + const message = text.trim(); + if (/invalid api key|unauthorized/i.test(message)) { + return ApiErr.credentialsRejected(); + } + if (/not found/i.test(message)) { + return ApiErr.notFound('post'); + } + if (UNREACHABLE.test(message)) { + return null; + } + return ApiErr.validationError(`Notra turned it down: ${message || 'it didn’t say why'}`); +} + +export const NOTRA: McpServer = { + name: 'Notra', + url: 'https://mcp.usenotra.com/mcp', + registerUrl: `${AUTH_BASE}/register`, + authorizeUrl: `${AUTH_BASE}/authorize`, + tokenUrl: `${AUTH_BASE}/token`, + scope: 'openid offline_access', + handshake: true, + toolError: notraToolError, +}; + +export function parseJson(text: string, schema: z.ZodType): Result { + return Result.wrap((): unknown => JSON.parse(text)) + .mapErr((cause) => ApiErr.providerUnreachable(cause)) + .andThen((json) => parseSchema(schema, json).mapErr((error) => ApiErr.providerUnreachable(error))); +} + +const whoAmISchema = z.object({workspace: z.object({name: z.string(), slug: z.string()})}); + +/** + * The Notra workspace a sign-in or a key is for, by its name and slug, as `whoami` names them: + * "Acme · acme". A key for a workspace renews its connection, as signing in to it again does. + */ +export function getNotraAccount(accessToken: string): Promise> { + return Do(async ($) => { + const {workspace} = $(parseJson($(await callMcpTool(NOTRA, accessToken, 'whoami', {})), whoAmISchema)); + return `${workspace.name} · ${workspace.slug}`; + }); +} + +export type NotraPost = { + id: string; + title: string; + contentType: string; + status: string; + slug: string; + updatedAt: string; + markdown: string; +}; + +const postSchema = z.object({ + id: z.string().min(1), + title: z.string(), + contentType: z.string(), + status: z.string(), + slug: z.string().nullish(), + updatedAt: z.string(), + // A full post's; a list's posts have an excerpt instead. + markdown: z.string().nullish(), +}); + +function toPost(post: z.infer): NotraPost { + return {...post, title: post.title || 'Untitled post', slug: post.slug ?? '', markdown: post.markdown ?? ''}; +} + +const postsSchema = z.object({posts: z.array(postSchema), pagination: z.object({nextPage: z.number().nullable()})}); + +/** A `list_posts` answer's posts, and the number of the next page, if there is one. */ +export function parseNotraPosts(text: string): Result<{posts: NotraPost[]; nextPage: number | null}, ApiError> { + return parseJson(text, postsSchema).map((page) => ({posts: page.posts.map(toPost), nextPage: page.pagination.nextPage})); +} + +/** A `get_post`, `create_post` or `update_post` answer's post. */ +export function parseNotraPost(text: string): Result { + return parseJson(text, z.object({post: postSchema})).map(({post}) => toPost(post)); +} diff --git a/packages/api/src/connections/notra/route.ts b/packages/api/src/connections/notra/route.ts new file mode 100644 index 00000000..8d94f3db --- /dev/null +++ b/packages/api/src/connections/notra/route.ts @@ -0,0 +1,31 @@ +import {textParam, type BuiltInMcp} from '../mcp/built-in'; +import {getNotraAccount, NOTRA} from './notra-mcp'; + +/** + * Notra, through its MCP server, as Linear is, signed in to or given one of its API keys, which its + * server takes as a bearer token too. A new Notra connection lets agents read its posts by default; + * creating, editing, publishing and deleting are turned on on the connection's page. Its + * `update_post` both edits and publishes, by whether it is given a status. + */ +export const NOTRA_MCP: BuiltInMcp = { + integrationId: 'notra', + server: NOTRA, + account: getNotraAccount, + // Notra checks a key only when a tool is called, so asking who it is for is the check. + apiKey: {check: async (key) => (await getNotraAccount(key)).map(() => undefined)}, + defaults: ['read'], + tools: { + list_posts: {actions: ['read'], run: () => ({collectionId: 'posts', operation: 'list'})}, + get_post: {actions: ['read'], run: (params) => ({collectionId: 'posts', operation: 'view', title: textParam(params, 'postId')})}, + create_post: {actions: ['create', 'publish'], run: (params) => ({collectionId: 'posts', operation: 'create', title: textParam(params, 'title')})}, + update_post: { + actions: ['edit', 'publish'], + run: (params) => { + const status = textParam(params, 'status'); + const operation = status === null ? 'update' : status === 'published' ? 'publish' : 'unpublish'; + return {collectionId: 'posts', operation, title: textParam(params, 'postId')}; + }, + }, + delete_post: {actions: ['delete'], run: (params) => ({collectionId: 'posts', operation: 'delete', title: textParam(params, 'postId')})}, + }, +}; diff --git a/packages/api/src/mcp/route.test.ts b/packages/api/src/mcp/route.test.ts index ead6dcda..d77267cd 100644 --- a/packages/api/src/mcp/route.test.ts +++ b/packages/api/src/mcp/route.test.ts @@ -89,7 +89,7 @@ describe('handleMcpRoute', () => { expect(result.instructions).toContain('Start with list_connections'); expect(result.instructions).toContain('- Email (alex@example.com), connection mail-1: Read, Archive'); expect(result.instructions).toContain('check Personal Agent Proxy first'); - expect(result.instructions).toContain('Personal Agent Proxy connects Email, Google, Granola, Notion, Linear, Exa, Parallel, Plaid, PostgreSQL, MySQL and any custom MCP server'); + expect(result.instructions).toContain('Personal Agent Proxy connects Email, Google, Granola, Notion, Linear, Exa, Parallel, Notra, Plaid, PostgreSQL, MySQL and any custom MCP server'); expect(result.instructions).toContain('https://app.example.com/connections/new'); expect(result.instructions).toContain('give them its url'); expect(result.instructions).toContain('send_feedback'); diff --git a/packages/api/src/records/connectors.ts b/packages/api/src/records/connectors.ts index e1eadcb5..cf9eb275 100644 --- a/packages/api/src/records/connectors.ts +++ b/packages/api/src/records/connectors.ts @@ -7,6 +7,7 @@ import {granolaConnector} from './granola-connector'; import {infoConnector} from './info-connector'; import {linearConnector} from './linear-connector'; import {notionConnector} from './notion-connector'; +import {notraConnector} from './notra-connector'; import {plaidConnector} from './plaid-connector'; import {sqlConnector} from './sql-connector'; import {exaConnector, parallelConnector} from './web-connector'; @@ -20,6 +21,7 @@ const CONNECTORS: Record, Connector> = { linear: linearConnector, exa: exaConnector, parallel: parallelConnector, + notra: notraConnector, postgres: sqlConnector('postgres'), mysql: sqlConnector('mysql'), google: googleConnector, diff --git a/packages/api/src/records/notra-connector.ts b/packages/api/src/records/notra-connector.ts new file mode 100644 index 00000000..1783bd32 --- /dev/null +++ b/packages/api/src/records/notra-connector.ts @@ -0,0 +1,138 @@ +import {Err, Ok, type Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, Do} from '@proxy/utils'; +import {NOTRA_CONTENT_TYPES, NOTRA_CREATABLE_TYPES} from '@proxy/integrations'; + +import {callMcpToolFor} from '../connections/mcp/mcp-session'; +import {NOTRA, parseNotraPost, parseNotraPosts, type NotraPost} from '../connections/notra/notra-mcp'; +import type {Connector, DataRecord, ListQuery, RecordPage, RecordTarget, RecordValues} from './connector'; + +/** + * A Notra workspace's posts, fetched live from Notra's MCP server, by Notra's own ids. A list is the + * newest posts first, 50 to a page, narrowed to a type of post; Notra can't search them. A post is + * made as a draft, and published or taken back to a draft only by the Publish and Unpublish + * commands. An edit sends only the fields written differently; a post's type is set when it is + * made, and its content can't be emptied. + */ + +const PAGE_SIZE = 50; + +function callNotra(target: RecordTarget, name: string, args: Record): Promise> { + return callMcpToolFor(NOTRA, target.connection, name, args); +} + +// Notra's name for a type of post from how it is written, "Blog post"; null for one it doesn't have. +function contentTypeOf(written: string): string | null { + const entry = Object.entries(NOTRA_CONTENT_TYPES).find(([, label]) => label === written.trim()); + return entry ? entry[0] : null; +} + +function summaryValues(post: NotraPost): RecordValues { + return { + title: post.title, + type: NOTRA_CONTENT_TYPES[post.contentType] ?? post.contentType, + status: post.status === 'published' ? 'Published' : 'Draft', + updated: post.updatedAt, + }; +} + +function toRecord(post: NotraPost, values: RecordValues): DataRecord { + return {id: post.id, values, updatedAt: post.updatedAt}; +} + +function fullRecord(post: NotraPost): DataRecord { + return toRecord(post, {...summaryValues(post), slug: post.slug, content: post.markdown}); +} + +function notFoundAs(recordId: string) { + return (error: ApiError): ApiError => (error.kind === 'not_found' ? ApiErr.notFound('record', recordId) : error); +} + +function fetchPost(target: RecordTarget, recordId: string): Promise> { + return Do(async ($) => $(parseNotraPost($((await callNotra(target, 'get_post', {postId: recordId})).mapErr(notFoundAs(recordId)))))); +} + +function list(target: RecordTarget, query: ListQuery): Promise> { + return Do(async ($) => { + const page = query.page === null ? 1 : Number(query.page); + if (!Number.isInteger(page) || page < 1) { + return $(Err(ApiErr.validationError('Not a page of Notra posts'))); + } + const contentType = query.filter ? contentTypeOf(query.filter) : null; + if (query.filter && !contentType) { + return $(Err(ApiErr.validationError(`Notra has no ${query.filter} posts`))); + } + const text = $(await callNotra(target, 'list_posts', {sort: 'desc', limit: PAGE_SIZE, page, ...(contentType ? {contentType: [contentType]} : {})})); + const posts = $(parseNotraPosts(text)); + return {records: posts.posts.map((post) => toRecord(post, summaryValues(post))), nextPage: posts.nextPage === null ? null : String(posts.nextPage)}; + }); +} + +// What `update_post` takes for one field written differently. +function fieldArg(key: string, value: string): Result, ApiError> { + const trimmed = value.trim(); + if (key === 'title') { + return trimmed === '' ? Err(ApiErr.validationError('A post needs a title')) : Ok({title: trimmed}); + } + if (key === 'slug') { + return Ok({slug: trimmed === '' ? null : trimmed}); + } + if (key === 'content') { + return trimmed === '' ? Err(ApiErr.validationError('A post’s content can’t be emptied')) : Ok({markdown: value}); + } + if (key === 'type') { + return Err(ApiErr.validationError('A post’s type can’t be changed')); + } + return Err(ApiErr.validationError(`${key} can't be set`)); +} + +function updateArgs(fields: RecordValues): Result, ApiError> { + const args: Record = {}; + for (const [key, value] of Object.entries(fields)) { + const arg = fieldArg(key, value); + if (arg.isErr()) { + return arg; + } + Object.assign(args, arg.value); + } + return Ok(args); +} + +function setStatus(status: 'draft' | 'published') { + return (target: RecordTarget, recordId: string): Promise> => + Do(async ($) => fullRecord($(parseNotraPost($((await callNotra(target, 'update_post', {postId: recordId, status})).mapErr(notFoundAs(recordId))))))); +} + +export const notraConnector: Connector = { + list, + get: (target, recordId) => Do(async ($) => fullRecord($(await fetchPost(target, recordId)))), + create: (target, values) => + Do(async ($) => { + const title = (values.title ?? '').trim(); + if (!title) { + return $(Err(ApiErr.validationError('A post needs a title'))); + } + const type = (values.type ?? '').trim(); + const contentType = NOTRA_CREATABLE_TYPES.includes(type) ? contentTypeOf(type) : null; + if (!contentType) { + return $(Err(ApiErr.validationError(`A post’s type is one of ${NOTRA_CREATABLE_TYPES.join(', ')}`))); + } + const slug = (values.slug ?? '').trim(); + const content = values.content ?? ''; + const args = {title, contentType, status: 'draft', ...(slug ? {slug} : {}), ...(content.trim() ? {markdown: content} : {})}; + return fullRecord($(parseNotraPost($(await callNotra(target, 'create_post', args))))); + }), + update: (target, recordId, values) => + Do(async ($) => { + const post = $(await fetchPost(target, recordId)); + const current = fullRecord(post).values; + const changed = Object.fromEntries(Object.entries(values).filter(([key, value]) => (key === 'content' ? value !== current.content : value.trim() !== (current[key] ?? '')))); + if (Object.keys(changed).length === 0) { + return fullRecord(post); + } + const args = $(updateArgs(changed)); + return fullRecord($(parseNotraPost($((await callNotra(target, 'update_post', {postId: post.id, ...args})).mapErr(notFoundAs(recordId)))))); + }), + remove: (target, recordId) => Do(async ($) => void $((await callNotra(target, 'delete_post', {postId: recordId})).mapErr(notFoundAs(recordId)))), + commands: {publish: setStatus('published'), unpublish: setStatus('draft')}, +}; diff --git a/packages/docs/content/connections/notra.mdx b/packages/docs/content/connections/notra.mdx new file mode 100644 index 00000000..dc23cedb --- /dev/null +++ b/packages/docs/content/connections/notra.mdx @@ -0,0 +1,47 @@ +--- +title: Notra +description: Connect a Notra workspace for agents to read, write and publish posts. +--- + +Agents work with a Notra workspace's posts, its blog posts, changelogs and social posts, as the +person who connects Notra. Posts stay in Notra; nothing is copied. + +## Connect Notra + + + +### Sign in with Notra, or use an API key + +Open **Connections**, then **Add connection**, and **Notra**. Sign in, choose a workspace and an +access level, and approve access. Connecting another workspace makes another connection. + +Or choose the key beside it and paste an API key made in Notra under **API Keys**. A key is for +one workspace and carries the permissions picked when it was made, so a read-only key keeps agents +to reading, whatever the connection allows. The key is checked with Notra before the connection is +made. A key for a workspace already connected replaces that connection's sign-in. + +### Review the defaults + +A new Notra connection gives every agent Read posts. Create drafts, Edit posts, Publish posts and +Delete posts are off by default. Turn them on for every agent on the connection's page, or only for +one agent on the agent's page. + + + +## What agents can do + +- **Read posts** lists and opens posts, newest first, with their Markdown. Lists can be narrowed by + type, such as Blog post or Changelog. +- **Create drafts** adds a blog post, changelog, LinkedIn post or X post as a draft, which nobody + sees until it is published. +- **Edit posts** changes a post's title, slug and content. Editing a published post changes it + where it is published, and editing one with a linked GitHub pull request updates that pull + request. It requires Read posts. +- **Publish posts** publishes a draft, or takes a published post back to a draft. A published post + can appear on the workspace's hosted site. It requires Read posts. +- **Delete posts** deletes a post for good, taking it down where it was published. It requires + Read posts. + +A post's type is set when it is made and can't be changed. Generating posts from GitHub activity, +brand identities, schedules and GEO aren't offered through Personal Agent Proxy; they stay in +Notra. diff --git a/packages/docs/content/index.mdx b/packages/docs/content/index.mdx index bcc3cbb5..953a7e48 100644 --- a/packages/docs/content/index.mdx +++ b/packages/docs/content/index.mdx @@ -13,7 +13,8 @@ The connection guides explain the integrations available today: [Information](/c for details stored in Personal Agent Proxy, [Email](/connections/email) for IMAP and SMTP mailboxes, [Google](/connections/google) for Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts, [Granola](/connections/granola) for meeting notes and transcripts, [Notion](/connections/notion) -for pages and databases, [Linear](/connections/linear) for issues, [Exa](/connections/exa) or +for pages and databases, [Linear](/connections/linear) for issues, [Notra](/connections/notra) for +blog posts, changelogs and social posts, [Exa](/connections/exa) or [Parallel](/connections/parallel) for searching and reading the web, and [PostgreSQL](/connections/postgres) or [MySQL](/connections/mysql) for a database's tables and queries. [Custom MCP servers](/connections/custom-mcp) explains how to connect any other service diff --git a/packages/docs/src/nav.ts b/packages/docs/src/nav.ts index 3741c6fb..e94d0e8d 100644 --- a/packages/docs/src/nav.ts +++ b/packages/docs/src/nav.ts @@ -24,6 +24,7 @@ export const NAV: {title: string; pages: NavEntry[]}[] = [ 'connections/granola', 'connections/notion', 'connections/linear', + 'connections/notra', 'connections/exa', 'connections/parallel', 'connections/plaid', diff --git a/packages/integrations/src/catalog.ts b/packages/integrations/src/catalog.ts index 445cbc89..3459f216 100644 --- a/packages/integrations/src/catalog.ts +++ b/packages/integrations/src/catalog.ts @@ -554,6 +554,118 @@ const parallel = webIntegration( 'https://platform.parallel.ai', ); +// The kinds of post Notra makes, by its own names for them, as a post's type is written. +export const NOTRA_CONTENT_TYPES: Record = { + blog_post: 'Blog post', + changelog: 'Changelog', + linkedin_post: 'LinkedIn post', + twitter_post: 'X post', + investor_update: 'Investor update', + image: 'Image', +}; + +// The kinds a post can be made as: investor updates and images are only ever generated in Notra. +export const NOTRA_CREATABLE_TYPES = ['Blog post', 'Changelog', 'LinkedIn post', 'X post']; + +// A Notra workspace's posts, through Notra's MCP server, signed in to or given one of its API +// keys: blog posts, changelogs and social posts, written as Markdown. A post is made as a draft +// and only reaches anyone once published, on the workspace's hosted site, its linked GitHub pull +// request or wherever it was scheduled, so publishing is an action of its own. Editing a published +// post, or one with a linked pull request, changes what is out there too. +const notra: Integration = { + id: 'notra', + name: 'Notra', + description: 'Blog posts, changelogs and social posts in a workspace', + signIn: true, + apiKey: {keysUrl: 'https://app.usenotra.com'}, + actions: [ + { + id: 'read', + label: 'Read posts', + description: 'List and open posts, drafts and published ones', + risk: 'low', + }, + { + id: 'create', + label: 'Create drafts', + description: 'Add posts as drafts, which nobody sees until they are published', + risk: 'low', + }, + { + id: 'edit', + label: 'Edit posts', + description: + 'Change the title, slug and content of posts, published ones and their linked pull requests too', + risk: 'medium', + requires: 'read', + }, + { + id: 'publish', + label: 'Publish posts', + description: 'Publish drafts, and take published posts back to drafts', + risk: 'high', + requires: 'read', + reachesOthers: true, + }, + { + id: 'delete', + label: 'Delete posts', + description: 'Delete posts, taking published ones down', + risk: 'high', + requires: 'read', + }, + ], + collections: [ + { + id: 'posts', + name: 'Posts', + singular: 'post', + titleField: 'title', + summaryField: 'type', + filterField: 'type', + read: 'read', + writes: {create: 'create', update: 'edit', delete: 'delete'}, + commands: [ + { + id: 'publish', + on: 'record', + label: 'Publish', + action: 'publish', + where: {field: 'status', values: ['Draft']}, + done: 'Published {}', + tried: 'publish {}', + }, + { + id: 'unpublish', + on: 'record', + label: 'Unpublish', + action: 'publish', + where: {field: 'status', values: ['Published']}, + done: 'Unpublished {}', + tried: 'unpublish {}', + }, + ], + fields: [ + {key: 'title', label: 'Title', type: 'text'}, + // Set when a post is made, never changed. + {key: 'type', label: 'Type', type: 'select', options: Object.values(NOTRA_CONTENT_TYPES)}, + { + key: 'status', + label: 'Status', + type: 'select', + options: ['Draft', 'Published'], + system: true, + }, + // Blog posts and changelogs only: lowercase words between hyphens, "launch-week". + {key: 'slug', label: 'Slug', type: 'text'}, + {key: 'updated', label: 'Updated', type: 'datetime', system: true}, + // Markdown. + {key: 'content', label: 'Content', type: 'longtext'}, + ], + }, + ], +}; + // A SQL database, signed in to with a database user's name and password: PostgreSQL, or MySQL // and MariaDB. What the user may do in the database is the limit, so access is narrowed in // Personal Agent Proxy: the tables and their rows are one tree, a table holding its rows, and a @@ -1259,6 +1371,7 @@ export const INTEGRATIONS: Integration[] = [ linear, exa, parallel, + notra, plaid, postgres, mysql, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 23379e11..dd1c4de7 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -15,6 +15,8 @@ export { isOffered, LINEAR_PRIORITIES, LINEAR_STATUS_TYPES, + NOTRA_CONTENT_TYPES, + NOTRA_CREATABLE_TYPES, } from './catalog'; export {allowedTools, findTool, integrationTools, toolName} from './tools'; export type { diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 924ec9f7..d138ba01 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -105,6 +105,7 @@ export type IntegrationId = | 'linear' | 'exa' | 'parallel' + | 'notra' | 'postgres' | 'mysql' | 'mcp' diff --git a/packages/web/public/integrations/notra.svg b/packages/web/public/integrations/notra.svg new file mode 100644 index 00000000..3e5fc1a9 --- /dev/null +++ b/packages/web/public/integrations/notra.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/web/src/lib/integrations.ts b/packages/web/src/lib/integrations.ts index 3878038c..84a1f04a 100644 --- a/packages/web/src/lib/integrations.ts +++ b/packages/web/src/lib/integrations.ts @@ -40,7 +40,7 @@ const NEEDED_BY: Partial> = {google: 'google', plaid // The integrations whose logo isn't `public/integrations/.svg` as it is: an icon for those // that are no one brand's, Information being ours and Email any mailbox, and our mark for custom // MCP servers, since Personal Agent Proxy is the service connecting them; another kind of file; -// or a logo that is already a square tile, as Granola's and Linear's are, which fills the tile +// or a logo that is already a square tile, as Granola's, Linear's and Notra's are, which fills the tile // edge to edge. const LOGOS: Partial> = { info: IdCardIcon, @@ -48,6 +48,7 @@ const LOGOS: Partial> = mcp: {src: '/favicon.svg'}, granola: {src: '/integrations/granola.png', fill: true}, linear: {src: '/integrations/linear.svg', fill: true}, + notra: {src: '/integrations/notra.svg', fill: true}, }; /** An integration's logo: its file in `public/integrations/`, named by its id, unless `LOGOS` has another. */