@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
280 lines • 10.3 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { asObject, isRecord } from "../../utils/records.js";
import { apiRequest } from "../api/client.js";
import { ApiError } from "../api/errors.js";
import { API_VERSION, DEFAULT_API_BASE_URL } from "./constants.js";
const RATE_LIMIT_RETRY = {
statuses: new Set([429, 529]),
maxRetries: 3,
maxBackoff: Infinity,
delaySource: 'header',
};
const DEFAULT_SERVER_URL = 'https://mcp.notion.com/mcp';
const CLIENT_NAME = 'mirage-notion';
const CLIENT_VERSION = '0.0.0';
export class NotionMCPError extends Error {
code;
raw;
constructor(message, opts) {
super(message);
this.name = 'NotionMCPError';
this.code = opts?.code ?? null;
this.raw = opts?.raw;
}
}
function firstTextContent(content) {
if (!Array.isArray(content) || content.length === 0)
return null;
const first = content[0];
if (!isRecord(first))
return null;
if (first.type !== 'text')
return null;
const text = first.text;
return typeof text === 'string' ? text : null;
}
export class MCPNotionTransport {
client = null;
connectPromise = null;
authProvider;
serverUrl;
constructor(opts) {
this.authProvider = opts.authProvider;
this.serverUrl = opts.serverUrl ?? DEFAULT_SERVER_URL;
}
async ensureConnected() {
if (this.connectPromise === null) {
this.client ??= new Client({ name: CLIENT_NAME, version: CLIENT_VERSION });
const client = this.client;
const transport = new StreamableHTTPClientTransport(new URL(this.serverUrl), {
authProvider: this.authProvider,
});
this.connectPromise = client.connect(transport);
}
await this.connectPromise;
if (this.client === null) {
throw new NotionMCPError('client was not initialized');
}
return this.client;
}
async callTool(name, args) {
const client = await this.ensureConnected();
const callable = client;
const result = await callable.callTool({ name, arguments: args });
if (result.isError === true) {
const text = firstTextContent(result.content) ?? 'tool returned error';
throw new NotionMCPError(`Notion MCP tool error: ${text}`, { raw: result });
}
if (isRecord(result.structuredContent))
return result.structuredContent;
if (!Array.isArray(result.content) || result.content.length === 0) {
throw new NotionMCPError('empty tool result', { raw: result });
}
const text = firstTextContent(result.content);
if (text === null) {
throw new NotionMCPError('empty tool result', { raw: result });
}
let parsed;
try {
parsed = JSON.parse(text);
}
catch {
throw new NotionMCPError('failed to parse tool result', { raw: text });
}
if (!isRecord(parsed)) {
throw new NotionMCPError('failed to parse tool result', { raw: text });
}
return parsed;
}
}
/**
* An `OAuthClientProvider` that holds the client registration, tokens and
* PKCE verifier in memory, for an `MCPNotionTransport` whose session ends
* with the page.
*/
export class MemoryOAuthClientProvider {
opts;
_clientInformation;
_tokens;
_codeVerifier;
constructor(opts) {
this.opts = opts;
}
get redirectUrl() {
return this.opts.redirectUrl;
}
get clientMetadata() {
return this.opts.clientMetadata;
}
clientInformation() {
return this._clientInformation;
}
saveClientInformation(info) {
this._clientInformation = info;
}
tokens() {
return this._tokens;
}
saveTokens(tokens) {
this._tokens = tokens;
}
redirectToAuthorization(authorizationUrl) {
return this.opts.redirect(authorizationUrl);
}
saveCodeVerifier(codeVerifier) {
this._codeVerifier = codeVerifier;
}
codeVerifier() {
if (this._codeVerifier === undefined) {
throw new Error('no code verifier saved');
}
return this._codeVerifier;
}
clearTokens() {
this._tokens = undefined;
}
}
export class NotionAPIError extends ApiError {
code;
constructor(message, status = null, code = null) {
super(message, status);
this.code = code;
this.name = 'NotionAPIError';
}
}
function errorOf(response, body) {
let data = {};
try {
const parsed = JSON.parse(body);
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
data = parsed;
}
}
catch {
// a non-JSON error body: report the status instead
}
const message = typeof data.message === 'string' && data.message !== ''
? data.message
: `Notion API error: HTTP ${String(response.status)}`;
const code = typeof data.code === 'string' ? data.code : null;
return new NotionAPIError(message, response.status, code);
}
/** Refuse a list response Notion marked incomplete. */
export function completePage(page) {
const status = asObject(page.request_status);
if (status.type === 'incomplete') {
const reason = typeof status.incomplete_reason === 'string' ? status.incomplete_reason : 'unknown';
throw new NotionAPIError(`Notion query incomplete: ${reason}`, null, reason);
}
return page;
}
function restCallFor(name, args) {
if (name === 'API-post-search') {
return { method: 'POST', path: '/search', body: args };
}
if (name === 'API-post-page') {
return { method: 'POST', path: '/pages', body: args };
}
if (name === 'API-retrieve-a-page') {
const { page_id, ...rest } = args;
return { method: 'GET', path: `/pages/${String(page_id)}`, query: rest };
}
if (name === 'API-retrieve-block-children') {
const { block_id, ...rest } = args;
return { method: 'GET', path: `/blocks/${String(block_id)}/children`, query: rest };
}
if (name === 'API-retrieve-a-database') {
const { database_id, ...rest } = args;
return { method: 'GET', path: `/databases/${String(database_id)}`, query: rest };
}
if (name === 'API-retrieve-a-data-source') {
const { data_source_id, ...rest } = args;
return { method: 'GET', path: `/data_sources/${String(data_source_id)}`, query: rest };
}
if (name === 'API-post-data-source-query') {
const { data_source_id, ...rest } = args;
return { method: 'POST', path: `/data_sources/${String(data_source_id)}/query`, body: rest };
}
if (name === 'API-patch-block-children') {
const { block_id, ...rest } = args;
return { method: 'PATCH', path: `/blocks/${String(block_id)}/children`, body: rest };
}
if (name === 'API-patch-page') {
const { page_id, ...rest } = args;
return { method: 'PATCH', path: `/pages/${String(page_id)}`, body: rest };
}
if (name === 'API-create-a-comment') {
return { method: 'POST', path: '/comments', body: args };
}
if (name === 'API-get-self') {
return { method: 'GET', path: '/users/me' };
}
if (name === 'API-retrieve-page-markdown') {
const { page_id, ...rest } = args;
return { method: 'GET', path: `/pages/${String(page_id)}/markdown`, query: rest };
}
if (name === 'API-patch-page-markdown') {
const { page_id, ...rest } = args;
return { method: 'PATCH', path: `/pages/${String(page_id)}/markdown`, body: rest };
}
throw new NotionAPIError(`unsupported Notion tool: ${name}`);
}
export class HttpNotionTransport {
fetch = globalThis.fetch.bind(globalThis);
apiKey;
baseUrl;
apiVersion;
timeout;
constructor(opts) {
this.apiKey = opts.apiKey;
this.baseUrl = opts.baseUrl ?? DEFAULT_API_BASE_URL;
this.apiVersion = opts.apiVersion ?? API_VERSION;
this.timeout = opts.timeout;
}
// The one place a request is actually made. `ntn api` is a typed path
// through the same entry point rather than a second client, so a header or an
// error shape can never differ between a named verb and a raw call.
async request(call) {
const params = {};
for (const [key, value] of Object.entries(call.query ?? {})) {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
params[key] = value;
}
}
// GET and DELETE carry no body: the only route DELETE exists on is
// /v1/blocks/{id}, whose whole payload is the id in the path.
const withBody = call.method !== 'GET' && call.method !== 'DELETE';
const data = await apiRequest(call.method, `${this.baseUrl}${call.path}`, {
fetchFn: this.fetch,
timeout: this.timeout,
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Notion-Version': this.apiVersion,
'Content-Type': 'application/json',
...(call.headers ?? {}),
},
params,
...(withBody ? { json: call.body ?? {} } : {}),
errorOf: errorOf,
retry: RATE_LIMIT_RETRY,
});
return (data ?? {});
}
async callTool(name, args) {
return this.request(restCallFor(name, args));
}
}
//# sourceMappingURL=client.js.map