Skip to content
Closed
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
3 changes: 2 additions & 1 deletion packages/api/src/connections/built-in-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions packages/api/src/connections/notra/notra-mcp.ts
Original file line number Diff line number Diff line change
@@ -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<T>(text: string, schema: z.ZodType<T>): Result<T, ApiError> {
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<Result<string, ApiError>> {
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<typeof postSchema>): 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<NotraPost, ApiError> {
return parseJson(text, z.object({post: postSchema})).map(({post}) => toPost(post));
}
31 changes: 31 additions & 0 deletions packages/api/src/connections/notra/route.ts
Original file line number Diff line number Diff line change
@@ -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')})},
},
};
2 changes: 1 addition & 1 deletion packages/api/src/mcp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/records/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,7 @@ const CONNECTORS: Record<Exclude<IntegrationId, 'mcp'>, Connector> = {
linear: linearConnector,
exa: exaConnector,
parallel: parallelConnector,
notra: notraConnector,
postgres: sqlConnector('postgres'),
mysql: sqlConnector('mysql'),
google: googleConnector,
Expand Down
138 changes: 138 additions & 0 deletions packages/api/src/records/notra-connector.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<Result<string, ApiError>> {
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<Result<NotraPost, ApiError>> {
return Do(async ($) => $(parseNotraPost($((await callNotra(target, 'get_post', {postId: recordId})).mapErr(notFoundAs(recordId))))));
}

function list(target: RecordTarget, query: ListQuery): Promise<Result<RecordPage, ApiError>> {
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<Record<string, unknown>, 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<Record<string, unknown>, ApiError> {
const args: Record<string, unknown> = {};
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<Result<DataRecord, ApiError>> =>
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')},
};
47 changes: 47 additions & 0 deletions packages/docs/content/connections/notra.mdx
Original file line number Diff line number Diff line change
@@ -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

<Steps>

### 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.

</Steps>

## 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.
3 changes: 2 additions & 1 deletion packages/docs/content/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/docs/src/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading