UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

280 lines • 10.3 kB
// ========= 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