@apiosk/mcp 1.7.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -612
- package/assets/brand/apiosk-a-20260921.png +0 -0
- package/assets/brand/apple-touch-icon.png +0 -0
- package/assets/brand/favicon-dark.ico +0 -0
- package/assets/brand/favicon-light.ico +0 -0
- package/assets/brand/favicon.ico +0 -0
- package/assets/brand/icon-192.png +0 -0
- package/assets/brand/icon-512.png +0 -0
- package/assets/brand/icon-maskable-512.png +0 -0
- package/assets/brand/inter-OFL.txt +93 -0
- package/assets/brand/inter-latin-400-normal.woff2 +0 -0
- package/assets/brand/inter-latin-500-normal.woff2 +0 -0
- package/assets/brand/inter-latin-600-normal.woff2 +0 -0
- package/assets/brand/mark-20260905-transparent.svg +1 -0
- package/assets/brand/mark-20260918.svg +1 -0
- package/assets/brand/mark-black-20260918.svg +1 -0
- package/assets/brand/mark-dark-20260905-transparent.png +0 -0
- package/assets/brand/mark-dark-20260918.png +0 -0
- package/assets/brand/mark-dark-96.png +0 -0
- package/assets/brand/mark-light-20260905-transparent.png +0 -0
- package/assets/brand/mark-light-20260918.png +0 -0
- package/assets/brand/mark-light-96.png +0 -0
- package/assets/brand/wordmark-black-320.png +0 -0
- package/assets/brand/wordmark-white-320.png +0 -0
- package/docs/branding.md +11 -0
- package/docs/marketplace-submission-2026-04-08.md +4 -0
- package/docs/marketplace-submission-2026-08-20.md +157 -0
- package/docs/openai-plugin-submission-2026-09-05.md +143 -0
- package/dxt.json +26 -22
- package/index.mjs +3 -1
- package/logo-optimized-light.png +0 -0
- package/package.json +14 -14
- package/plugin/apiosk/.codex-plugin/plugin.json +45 -0
- package/plugin/apiosk/.mcp.json +8 -0
- package/plugin/apiosk/assets/icon-dark.png +0 -0
- package/plugin/apiosk/assets/icon.png +0 -0
- package/plugin/apiosk/assets/icon.svg +1 -0
- package/plugin/apiosk/assets/logo.png +0 -0
- package/plugin/apiosk/skills/apiosk/SKILL.md +33 -0
- package/plugin/apiosk/skills/apiosk/agents/openai.yaml +12 -0
- package/server.json +58 -24
- package/server.mjs +136 -151
- package/src/approval-feedback.mjs +21 -0
- package/src/brand-routes.mjs +28 -0
- package/src/create-server.mjs +160 -46
- package/src/display-money.mjs +20 -0
- package/src/display-text.mjs +67 -0
- package/src/gateway-client.mjs +42 -0
- package/src/gateway-v2-ask.mjs +50 -0
- package/src/gateway-v2-card-account.mjs +23 -0
- package/src/gateway-v2-card-actions.mjs +67 -0
- package/src/gateway-v2-card-answer-text.mjs +130 -0
- package/src/gateway-v2-card-answer.mjs +68 -0
- package/src/gateway-v2-card-blocks.mjs +166 -0
- package/src/gateway-v2-card-body.mjs +259 -0
- package/src/gateway-v2-card-budget.mjs +21 -0
- package/src/gateway-v2-card-cbs.mjs +56 -0
- package/src/gateway-v2-card-choices.mjs +4 -0
- package/src/gateway-v2-card-clarification.mjs +25 -0
- package/src/gateway-v2-card-compact.mjs +34 -0
- package/src/gateway-v2-card-events.mjs +41 -0
- package/src/gateway-v2-card-presentation.mjs +125 -0
- package/src/gateway-v2-card-research.mjs +63 -0
- package/src/gateway-v2-card-result.mjs +32 -0
- package/src/gateway-v2-card-search.mjs +39 -0
- package/src/gateway-v2-card-sources.mjs +32 -0
- package/src/gateway-v2-card-style.mjs +76 -0
- package/src/gateway-v2-card-verdict.mjs +66 -0
- package/src/gateway-v2-card.mjs +91 -0
- package/src/gateway-v2-contracts.json +66 -0
- package/src/gateway-v2-instructions.md +113 -0
- package/src/gateway-v2-recovery.mjs +18 -0
- package/src/gateway-v2-report-links.mjs +14 -0
- package/src/gateway-v2-workflows.mjs +17 -0
- package/src/gateway-v2.mjs +179 -0
- package/src/oauth.mjs +402 -1949
- package/src/observability.mjs +19 -3
- package/src/result-presentation.mjs +9 -0
- package/src/runtime.mjs +24 -3670
- package/src/source-groups.mjs +91 -0
- package/src/source-value-format.mjs +25 -0
- package/src/tool-result.mjs +20 -0
- package/src/ui-bridge.mjs +185 -0
- package/src/well-known-routes.mjs +132 -0
- package/src/assets/wallet-accounts.mjs +0 -20513
- package/src/assets/walletconnect-provider.mjs +0 -6319
- package/src/discovery.mjs +0 -929
- package/src/external-fetch.mjs +0 -203
- package/src/funding-options.mjs +0 -255
- package/src/gateway-management.mjs +0 -107
- package/src/hosted-payment.mjs +0 -552
- package/src/hosted-wallets.mjs +0 -530
- package/src/listing-metadata.mjs +0 -287
- package/src/local-config.mjs +0 -256
- package/src/payment-guidance.mjs +0 -298
- package/src/publisher.mjs +0 -1288
- package/src/result-canvas.mjs +0 -16
- package/src/source-registry.mjs +0 -215
- package/src/wallet-store.mjs +0 -476
- package/src/x402-inspect.mjs +0 -361
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Source browsing contract: grouped providers and the /v2/sources output schema.
|
|
2
|
+
//
|
|
3
|
+
// A grouped provider is ONE directory source whose listings are its services.
|
|
4
|
+
// The gateway groups by the stored owner handle (gateway/src/sources.rs
|
|
5
|
+
// GROUPED_PROVIDERS); this copy only words the chatbot contract so a host never
|
|
6
|
+
// counts those services as separate sources. Keep the three copies identical:
|
|
7
|
+
// gateway/src/sources.rs, app/src/lib/domain/source-directory.ts and this file.
|
|
8
|
+
export const GROUPED_PROVIDERS = Object.freeze([
|
|
9
|
+
Object.freeze({ handle: "pulsenetwork", name: "Pulse Network", description: "Data services published by Pulse Network.", category: "intelligence" }),
|
|
10
|
+
Object.freeze({ handle: "coinbase-bazaar", name: "Coinbase Bazaar", description: "Third-party pay-per-call services listed in the Coinbase x402 Bazaar. Apiosk buys each call for you from your balance.", category: "marketplace" }),
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const groupedNames = (() => {
|
|
14
|
+
const names = GROUPED_PROVIDERS.map(provider => provider.name);
|
|
15
|
+
return names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0];
|
|
16
|
+
})();
|
|
17
|
+
|
|
18
|
+
/** Tool-description sentence: each grouped provider is one source. */
|
|
19
|
+
export const GROUPED_SOURCES_TOOL_TEXT = `${groupedNames} are each one source with nested services; never count or list those services as separate sources in an overview.`;
|
|
20
|
+
/** Notice sentence carried on every browse result. */
|
|
21
|
+
export const GROUPED_SOURCES_NOTICE = `${groupedNames} are each one source; their nested services are not additional sources.`;
|
|
22
|
+
|
|
23
|
+
const capabilitySource = {
|
|
24
|
+
type: "object", additionalProperties: false, required: ["slug", "name"],
|
|
25
|
+
properties: { slug: { type: "string" }, name: { type: "string" } },
|
|
26
|
+
};
|
|
27
|
+
const capabilityGroup = {
|
|
28
|
+
type: "object", additionalProperties: false,
|
|
29
|
+
required: ["slug", "name", "primary_sources", "supplementary_sources"],
|
|
30
|
+
properties: {
|
|
31
|
+
slug: { type: "string" }, name: { type: "string" },
|
|
32
|
+
primary_sources: { type: "array", items: capabilitySource },
|
|
33
|
+
supplementary_sources: { type: "array", items: capabilitySource },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const sourceOutput = {
|
|
37
|
+
type: "object", additionalProperties: false,
|
|
38
|
+
properties: {
|
|
39
|
+
slug: { type: "string" }, provider_slug: { type: ["string", "null"] }, logo_url: { type: ["string", "null"] },
|
|
40
|
+
name: { type: "string" }, description: { type: "string" }, category: { type: "string" },
|
|
41
|
+
categories: { type: "array", items: { type: "string" } },
|
|
42
|
+
service_count: { type: "integer", minimum: 1, description: "Services within this one source; do not count them as separate sources." },
|
|
43
|
+
matching_service_count: { type: "integer", minimum: 1 },
|
|
44
|
+
services: { type: "array", items: { type: "object", additionalProperties: false, properties: { slug: { type: "string" }, name: { type: "string" }, description: { type: "string" }, category: { type: "string" } } } },
|
|
45
|
+
readiness: { type: "object", additionalProperties: true },
|
|
46
|
+
tags: { type: "array", items: { type: "string" } }, sectors: { type: "array", items: { type: "string" } },
|
|
47
|
+
endpoint_count: { type: "integer", minimum: 0, description: "Published endpoints in this source, not chatbot tools." },
|
|
48
|
+
capabilities: { type: "array", items: { type: "string" } }, input_types: { type: "array", items: { type: "string" } },
|
|
49
|
+
executable_capabilities: { type: "array", items: { type: "string" } },
|
|
50
|
+
capability_roles: { type: "object", additionalProperties: { type: "string", enum: ["primary", "supplementary"] } },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Output schema of apiosk_sources; `errorFields` are the shared failure fields. */
|
|
55
|
+
export function sourcesOutputSchema(errorFields) {
|
|
56
|
+
return {
|
|
57
|
+
type: "object", additionalProperties: false,
|
|
58
|
+
properties: {
|
|
59
|
+
protocol_version: { type: "string", const: "2" }, sources: { type: "array", items: sourceOutput },
|
|
60
|
+
total: { type: "integer", minimum: 0 }, offset: { type: "integer", minimum: 0 },
|
|
61
|
+
next_offset: { type: ["integer", "null"], minimum: 0 }, categories: { type: "array", items: { type: "string" } },
|
|
62
|
+
tags: { type: "array", items: { type: "string" } }, sectors: { type: "array", items: { type: "string" } },
|
|
63
|
+
capabilities: { type: "array", items: { type: "string" } }, notice: { type: "string" }, ...errorFields,
|
|
64
|
+
capability_groups: { type: "array", items: capabilityGroup },
|
|
65
|
+
selected_capability: { anyOf: [capabilityGroup, { type: "null" }] },
|
|
66
|
+
},
|
|
67
|
+
anyOf: [{ required: ["protocol_version", "sources", "total", "offset", "categories", "tags", "sectors", "capabilities", "notice"] }, { required: ["error_code", "message"] }],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A browse result as the chatbot sees it: no internal readiness fields and the public notice. */
|
|
72
|
+
// A grouped provider lists every member's capabilities: Pulse Network alone
|
|
73
|
+
// carries hundreds. Hosts and models need a readable page, not the index.
|
|
74
|
+
const LISTED_CAPABILITIES = 12;
|
|
75
|
+
const FACET_LIMIT = 200;
|
|
76
|
+
const facet = values => Array.isArray(values) ? values.filter(value => !String(value).startsWith("operation.")).slice(0, FACET_LIMIT) : [];
|
|
77
|
+
|
|
78
|
+
export function presentSources(result) {
|
|
79
|
+
const { catalog_total: _catalogTotal, ...publicResult } = result;
|
|
80
|
+
return { ...publicResult, tags: facet(result.tags), capabilities: facet(result.capabilities),
|
|
81
|
+
sources: result.sources.map(({ available_in_v2: _available, can_answer_questions: _canAnswer, executable_capabilities: _executable, input_types: _inputs, readiness, capabilities, services, tags, categories, ...source }) => ({ ...source,
|
|
82
|
+
...(readiness && { readiness: { contracts: readiness.contracts } }),
|
|
83
|
+
...(Array.isArray(capabilities) && { capabilities: facet(capabilities).slice(0, LISTED_CAPABILITIES) }),
|
|
84
|
+
// A grouped provider carries every member's tags: tens of KB per row.
|
|
85
|
+
...(Array.isArray(tags) && { tags: tags.slice(0, LISTED_CAPABILITIES) }),
|
|
86
|
+
...(Array.isArray(categories) && { categories: categories.slice(0, LISTED_CAPABILITIES) }),
|
|
87
|
+
...(Array.isArray(services) && { services: services.map(service => typeof service.description === "string" ? { ...service, description: service.description.slice(0, 120) } : service) }),
|
|
88
|
+
})),
|
|
89
|
+
notice: `Browsing is free. Published sources may have execution or coverage restrictions. Capability groups list primary sources; supplementary sources are optional enrichment. Each source is counted once. ${GROUPED_SOURCES_NOTICE} Apiosk checks the exact question and price before any purchase.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatDisplayText, formatDisplayNarrative, isLiteralField } from './display-text.mjs';
|
|
2
|
+
// Display formatting only. Raw source JSON remains unchanged.
|
|
3
|
+
export function sourceCurrency(value) {
|
|
4
|
+
const candidate = typeof value === 'string' ? value : value?.currency ?? value?.currencyCode ?? value?.unit;
|
|
5
|
+
if (typeof candidate !== 'string') return null;
|
|
6
|
+
const code = candidate.replace(/^iso4217:/i, '').toUpperCase();
|
|
7
|
+
return /^(EUR|USD|GBP|CHF|JPY|CAD|AUD|SEK|NOK|DKK)$/.test(code) ? code : null;
|
|
8
|
+
}
|
|
9
|
+
export function formatSourceValue(value, key = '', currency = null, nameContext) {
|
|
10
|
+
const raw = String(value ?? '');
|
|
11
|
+
if (isLiteralField(key)) return raw;
|
|
12
|
+
if (typeof value === 'string') value = formatDisplayText(value, key);
|
|
13
|
+
const label = String(key).replace(/[\s_.-]/g, '');
|
|
14
|
+
// Identifiers, calendar years and dates are labels, not amounts.
|
|
15
|
+
if (/year|date|code|identifier|kvk|postcode|postal|phone|iban/i.test(label) || /(?:^id$|Id$|ID$|Number$|Nummer$)/.test(label)) return raw;
|
|
16
|
+
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw)) return formatDisplayNarrative(String(value ?? ''), nameContext);
|
|
17
|
+
const negative = raw.startsWith('-'), unsigned = negative ? raw.slice(1) : raw;
|
|
18
|
+
const [whole, fraction] = unsigned.split('.');
|
|
19
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (fraction == null ? '' : '.' + fraction);
|
|
20
|
+
const monetary = /^(Assets|Liabilities|Equity|Receivables|Cash|Property|Provisions|Share|RetainedEarnings|FinancialIncome|IncomeTax|Result|Depreciation|EmployeeBenefits|Operating|GrossMargin|Impairment|SumOfExpenses|Revenue|Profit|Turnover|Amount|Balance|Price|Cost|Tax)/i.test(label);
|
|
21
|
+
const code = monetary ? sourceCurrency(currency) : null;
|
|
22
|
+
const symbol = code && ({ EUR: '€', USD: '$', GBP: '£' }[code] || code);
|
|
23
|
+
return (negative ? '-' : '') + (symbol ? symbol + ' ' : '') + grouped;
|
|
24
|
+
}
|
|
25
|
+
export const V2_SOURCE_VALUE_FORMAT = [sourceCurrency, formatSourceValue].map(fn => fn.toString()).join('\n');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// One shape for every tool result.
|
|
2
|
+
//
|
|
3
|
+
// `structuredContent` is what a client can act on; the text block is what a
|
|
4
|
+
// model reads when the client has no structured channel. Both are always
|
|
5
|
+
// present, and they are always the same object, so an agent never has to guess
|
|
6
|
+
// which one is authoritative.
|
|
7
|
+
|
|
8
|
+
export function content(value) {
|
|
9
|
+
const result = {
|
|
10
|
+
content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
|
|
11
|
+
};
|
|
12
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
13
|
+
result.structuredContent = value;
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function trimString(value) {
|
|
19
|
+
return String(value ?? "").trim();
|
|
20
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// One card, two hosts, and the reason this is a string.
|
|
2
|
+
//
|
|
3
|
+
// A widget in this server has to run in two worlds that disagree about
|
|
4
|
+
// everything except the HTML: OpenAI's Apps SDK injects a `window.openai`
|
|
5
|
+
// object, and MCP Apps (SEP-1865) injects nothing at all and expects the iframe
|
|
6
|
+
// to speak JSON-RPC to its parent over postMessage. Written twice, the two
|
|
7
|
+
// paths drift.
|
|
8
|
+
//
|
|
9
|
+
// So the transport is written once, here, as the source text the card embeds.
|
|
10
|
+
// It is a template string rather than a module because a UI resource is ONE
|
|
11
|
+
// self-contained document: the host renders the HTML in a sandboxed iframe, so
|
|
12
|
+
// there is nothing to import from.
|
|
13
|
+
//
|
|
14
|
+
// What a card gets is four calls and no host detection:
|
|
15
|
+
//
|
|
16
|
+
// apiosk.onData(fn) fn(structuredContent) now and on every update
|
|
17
|
+
// apiosk.callTool(name,args) resolves with the tool's structuredContent
|
|
18
|
+
// apiosk.openLink(url) opens outside the iframe, or returns false
|
|
19
|
+
// apiosk.say(text) puts a message in the conversation as the user
|
|
20
|
+
//
|
|
21
|
+
// `apiosk.can.callTool` and `apiosk.can.say` say whether the host supports
|
|
22
|
+
// them, because a button that silently does nothing is worse than a button that
|
|
23
|
+
// is not there.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The bridge, as browser source.
|
|
27
|
+
*
|
|
28
|
+
* MCP Apps handshake, in order: send `ui/initialize`, then the
|
|
29
|
+
* `ui/notifications/initialized` notification, then wait for
|
|
30
|
+
* `ui/notifications/tool-result` — the host delivers the tool's output as a
|
|
31
|
+
* notification rather than as a global, so a card that only reads a global
|
|
32
|
+
* renders blank there forever.
|
|
33
|
+
*/
|
|
34
|
+
export const APIOSK_UI_BRIDGE = `
|
|
35
|
+
(()=>{
|
|
36
|
+
const listeners=[],inputListeners=[];let data=null,input=null,dataKey=null,localState=null,pending=new Map(),rpcId=0,mcp=false,host={};
|
|
37
|
+
function applyTheme(theme){if(theme!=='light'&&theme!=='dark')return;document.documentElement.dataset.theme=theme;document.documentElement.style.colorScheme=theme}
|
|
38
|
+
applyTheme(window.openai?.theme);
|
|
39
|
+
// Hosts may resend the original tool snapshot when widget state or layout
|
|
40
|
+
// changes. Replaying it would overwrite a newer in-card tool response and
|
|
41
|
+
// cancel the execution timer immediately after approval.
|
|
42
|
+
function emit(v){if(v==null)return;v=restoredView(v);if(localState?.state_ref===v?.state?.state_ref&&Number(v?.state?.revision)<=Number(localState?.revision))return;if(data?.state?.state_ref&&data.state.state_ref===v?.state?.state_ref&&Number(v.state.revision)<Number(data.state.revision))return;let key;try{key=JSON.stringify(v)}catch(e){}if(key!==undefined&&key===dataKey)return;dataKey=key;data=v;for(const fn of listeners){try{fn(v)}catch(e){}}}
|
|
43
|
+
function emitInput(v){if(v==null)return;input=v;for(const fn of inputListeners){try{fn(v)}catch(e){}}}
|
|
44
|
+
function unwrap(r){if(r&&typeof r==='object'){if(r.structuredContent)return r.structuredContent;
|
|
45
|
+
if(Array.isArray(r.content))for(const block of r.content){if(block?.type==='text'&&typeof block.text==='string'){try{const value=JSON.parse(block.text);if(value&&typeof value==='object')return value}catch(e){}}}}
|
|
46
|
+
return r}
|
|
47
|
+
// ---- MCP Apps (SEP-1865): JSON-RPC over postMessage to the host frame -------
|
|
48
|
+
function send(msg){try{window.parent.postMessage(msg,'*')}catch(e){}}
|
|
49
|
+
function rpc(method,params){return new Promise((resolve,reject)=>{const id=++rpcId;
|
|
50
|
+
const timer=setTimeout(()=>{if(pending.has(id)){pending.delete(id);reject(new Error('timeout'))}},180000);
|
|
51
|
+
pending.set(id,{resolve,reject,timer});send({jsonrpc:'2.0',id,method,params:params||{}})})}
|
|
52
|
+
window.addEventListener('message',event=>{if(event.source!==window.parent)return;const msg=event.data;
|
|
53
|
+
if(!msg||msg.jsonrpc!=='2.0')return;
|
|
54
|
+
if(msg.id!=null&&pending.has(msg.id)){const p=pending.get(msg.id);pending.delete(msg.id);
|
|
55
|
+
clearTimeout(p.timer);
|
|
56
|
+
msg.error?p.reject(new Error(msg.error.message||'host error')):p.resolve(msg.result);return}
|
|
57
|
+
if(msg.method==='ui/notifications/host-context-changed'){applyTheme(msg.params?.theme);return}
|
|
58
|
+
if(msg.method==='ui/notifications/tool-input'){emitInput(msg.params);return}
|
|
59
|
+
if(msg.method==='ui/notifications/tool-result'){emit(unwrap(msg.params));return}
|
|
60
|
+
// A host cancelling its local tool wait does not prove that the durable
|
|
61
|
+
// Gateway task stopped. Keep the latest task snapshot and its recovery
|
|
62
|
+
// controls; only a Gateway response may set status=cancelled.
|
|
63
|
+
if(msg.method==='ui/notifications/tool-cancelled'){return}});
|
|
64
|
+
// ---- OpenAI Apps SDK: globals plus an event ---------------------------------
|
|
65
|
+
function restoredView(raw){const o=window.openai,saved=o?.widgetState?.privateContent?.apioskResult??o?.widgetState?.result;
|
|
66
|
+
// Keep the last server-returned view for this card across remounts. This is
|
|
67
|
+
// a display cache only: the card still recovers server state and never starts
|
|
68
|
+
// a paid step from mounting or from persisted widget state.
|
|
69
|
+
if(saved?.state?.state_ref&&saved.state.state_ref===raw?.state?.state_ref&&Number(saved.state.revision)>=Number(raw.state.revision))return saved;
|
|
70
|
+
return raw}
|
|
71
|
+
function openaiData(){const o=window.openai;return o?(o.toolOutput??o.structuredContent??null):null}
|
|
72
|
+
window.addEventListener('openai:set_globals',e=>{const g=e.detail?.globals??e.detail;
|
|
73
|
+
if(g?.theme)applyTheme(g.theme);
|
|
74
|
+
if(g&&Object.prototype.hasOwnProperty.call(g,'toolInput'))emitInput(g.toolInput);
|
|
75
|
+
if(g&&Object.prototype.hasOwnProperty.call(g,'toolOutput'))emit(g.toolOutput)});
|
|
76
|
+
// ---- one surface over both ---------------------------------------------------
|
|
77
|
+
const api={
|
|
78
|
+
get data(){return data},
|
|
79
|
+
can:{callTool:false,say:false,openLink:false,purchase:false,autoFollowUp:false},
|
|
80
|
+
onInput(fn){inputListeners.push(fn);if(input!=null){try{fn(input)}catch(e){}}},
|
|
81
|
+
onData(fn){listeners.push(fn);if(data!=null){try{fn(data)}catch(e){}}},
|
|
82
|
+
async callTool(name,args){
|
|
83
|
+
if(window.openai&&window.openai.callTool)return unwrap(await window.openai.callTool(name,args||{}));
|
|
84
|
+
if(mcp)return unwrap(await rpc('tools/call',{name,arguments:args||{}}));
|
|
85
|
+
throw new Error('This host cannot run a tool from the card.')},
|
|
86
|
+
async openLink(url){
|
|
87
|
+
try{if(new URL(url).protocol!=='https:')return false}catch{return false}
|
|
88
|
+
if(window.openai&&window.openai.openExternal){window.openai.openExternal({href:url});return true}
|
|
89
|
+
if(mcp){try{await rpc('ui/open-link',{url});return true}catch(e){return false}}
|
|
90
|
+
return false},
|
|
91
|
+
async say(text){
|
|
92
|
+
if(window.openai&&window.openai.sendFollowUpMessage){
|
|
93
|
+
await window.openai.sendFollowUpMessage({prompt:text,scrollToBottom:true});return true}
|
|
94
|
+
if(mcp){try{const result=await rpc('ui/message',{role:'user',content:[{type:'text',text}]});return !result?.isError}catch(e){return false}}
|
|
95
|
+
return false},
|
|
96
|
+
async context(value){
|
|
97
|
+
// In-card tool responses are newer than the original host snapshot, even
|
|
98
|
+
// when saving consent did not change the task revision. Explicit server
|
|
99
|
+
// refreshes still render directly; this guard only rejects host replays.
|
|
100
|
+
if(value?.state){data=value;localState=value.state;try{dataKey=JSON.stringify(value)}catch(e){}}
|
|
101
|
+
let stored=false;if(window.openai?.setWidgetState){try{window.openai.setWidgetState({...window.openai.widgetState,modelContent:{status:value?.status,state_ref:value?.state?.state_ref,billing:value?.billing},privateContent:{...window.openai.widgetState?.privateContent,apioskResult:value}});stored=true}catch(e){}}
|
|
102
|
+
if(mcp&&host.updateModelContext){const summary={status:value?.status,state_ref:value?.state?.state_ref,billing:value?.billing,result:value?.result,errors:value?.errors};let contextText=JSON.stringify(summary);if(contextText.length>24000)contextText=JSON.stringify({status:value?.status,state_ref:value?.state?.state_ref,billing:value?.billing,note:'Read the saved task to retrieve its result.'});await rpc('ui/update-model-context',{content:[{type:'text',text:contextText}],structuredContent:value});return true}
|
|
103
|
+
return stored},
|
|
104
|
+
// Cards grow when a list renders. A host sizing an iframe once shows the first
|
|
105
|
+
// two rows of six and no scrollbar.
|
|
106
|
+
resize(){const width=Math.ceil(document.documentElement.scrollWidth),height=Math.ceil(document.documentElement.scrollHeight);
|
|
107
|
+
if(window.openai&&window.openai.notifyIntrinsicHeight){try{window.openai.notifyIntrinsicHeight(height)}catch(e){}}
|
|
108
|
+
if(mcp)send({jsonrpc:'2.0',method:'ui/notifications/size-changed',params:{width,height}})}};
|
|
109
|
+
window.apiosk=api;
|
|
110
|
+
(async()=>{
|
|
111
|
+
if(window.openai){api.can={callTool:!!window.openai.callTool,say:!!window.openai.sendFollowUpMessage,openLink:!!window.openai.openExternal,purchase:!!window.openai.callTool,autoFollowUp:!!window.openai.sendFollowUpMessage};emitInput(window.openai.toolInput);emit(openaiData())}
|
|
112
|
+
if(typeof ResizeObserver!=='undefined')new ResizeObserver(()=>api.resize()).observe(document.documentElement);
|
|
113
|
+
if(typeof requestAnimationFrame==='function')requestAnimationFrame(()=>api.resize());else setTimeout(()=>api.resize(),0);
|
|
114
|
+
if(window.parent===window)return;
|
|
115
|
+
try{
|
|
116
|
+
const result=await rpc('ui/initialize',{appInfo:{name:'Apiosk',version:'2.0.0'},protocolVersion:'2026-01-26',appCapabilities:{}});
|
|
117
|
+
mcp=true;host=(result&&result.hostCapabilities)||{};applyTheme(result?.hostContext?.theme);
|
|
118
|
+
api.can={callTool:!!host.serverTools,say:!!host.message,openLink:!!host.openLinks,purchase:!!host.serverTools&&!/claude/i.test(result?.hostInfo?.name||''),autoFollowUp:!!window.openai?.sendFollowUpMessage&&!/claude/i.test(result?.hostInfo?.name||'')};
|
|
119
|
+
send({jsonrpc:'2.0',method:'ui/notifications/initialized',params:{}});
|
|
120
|
+
}catch(e){/* not an MCP Apps host: the OpenAI path above, or nothing */}
|
|
121
|
+
})();
|
|
122
|
+
})();
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The metadata every Apiosk UI resource carries.
|
|
127
|
+
*
|
|
128
|
+
* BOTH VOCABULARIES, because the two hosts read different keys for the same
|
|
129
|
+
* three facts and a resource that answers only one of them renders in only one
|
|
130
|
+
* of them. The CSP is SET AND EMPTY on purpose: these cards fetch nothing —
|
|
131
|
+
* no script, style, font, image or endpoint — so the policy permits nothing
|
|
132
|
+
* outward, and paid provider data reaches the card only as tool output that the
|
|
133
|
+
* server already fetched.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} description what a reviewer and a host see the card do
|
|
136
|
+
*/
|
|
137
|
+
export function uiResourceMeta(description) {
|
|
138
|
+
return {
|
|
139
|
+
ui: {
|
|
140
|
+
prefersBorder: true,
|
|
141
|
+
domain: "https://mcp.apiosk.com",
|
|
142
|
+
csp: { connectDomains: [], resourceDomains: [] },
|
|
143
|
+
},
|
|
144
|
+
"openai/widgetCSP": { connect_domains: [], resource_domains: [] },
|
|
145
|
+
"openai/widgetDomain": "https://mcp.apiosk.com",
|
|
146
|
+
"openai/widgetPrefersBorder": true,
|
|
147
|
+
"openai/widgetDescription": description,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The App's design tokens (app/src/index.css), both themes. Dark follows the
|
|
152
|
+
// host's data-theme, else the system preference, and swaps the whole set.
|
|
153
|
+
const LIGHT_TOKENS = "color-scheme:light;--background:#fafafa;--background-2:oklch(.975 .004 265);--surface:oklch(1 0 0);--surface-muted:oklch(.965 .005 265);--surface-soft:#f4f4f5;--card:oklch(1 0 0);--foreground:#303036;--heading:#232329;--muted:oklch(.52 .02 265);--faint:oklch(.62 .018 265);--border:#e9e9ed;--border-2:#dddde3;--border-3:oklch(.82 .01 265);--accent:#6349db;--accent-fg:#fff;--accent-line:rgb(99 73 219/.4);--accent-wash:rgb(99 73 219/.07);--accent-wash-strong:rgb(99 73 219/.13);--secondary:oklch(.96 .006 265);--secondary-fg:oklch(.26 .02 265);--button-primary-bg-hover:#553cc5;--button-secondary-bg:oklch(1 0 0);--button-secondary-fg:oklch(.26 .02 265);--input-bg:oklch(1 0 0);--hover:oklch(.21 .02 265/.05);--success-bg:#e8faf1;--success-fg:#057857;--success-border:#c9efdc;--warning-bg:#fdf5e6;--warning-fg:#b26a12;--warning-border:#f2e2c4;--danger-bg:#fff2f4;--danger-fg:#b42318;--danger-border:#fecdd3";
|
|
154
|
+
const DARK_TOKENS = "color-scheme:dark;--background:oklch(.148 .014 265);--background-2:oklch(.172 .014 265);--surface:oklch(.185 .015 265);--surface-muted:oklch(.225 .015 265);--surface-soft:oklch(.258 .015 265);--card:oklch(.185 .015 265);--foreground:oklch(.925 .008 265);--heading:oklch(.975 .004 265);--muted:oklch(.715 .018 265);--faint:oklch(.565 .02 265);--border:rgba(255,255,255,.08);--border-2:rgba(255,255,255,.12);--border-3:rgba(255,255,255,.18);--accent:#c3a0ff;--accent-fg:#25153c;--accent-line:rgb(195 160 255/.45);--accent-wash:rgb(195 160 255/.12);--accent-wash-strong:rgb(195 160 255/.2);--secondary:oklch(.265 .018 265);--secondary-fg:oklch(.93 .008 265);--button-primary-bg-hover:#d2b8ff;--button-secondary-bg:oklch(.225 .015 265);--button-secondary-fg:oklch(.925 .008 265);--input-bg:oklch(.172 .014 265);--hover:rgba(255,255,255,.06);--success-bg:rgba(16,185,129,.14);--success-fg:#6ee7b7;--success-border:rgba(16,185,129,.28);--warning-bg:rgba(251,191,36,.14);--warning-fg:#fbbf24;--warning-border:rgba(251,191,36,.28);--danger-bg:rgba(244,63,94,.14);--danger-fg:#fda4af;--danger-border:rgba(244,63,94,.28)";
|
|
155
|
+
const FONT = weight => `@font-face{font-family:Inter;src:url("https://mcp.apiosk.com/brand/inter-latin-${weight}-normal.woff2") format("woff2");font-style:normal;font-weight:${weight};font-display:swap}`;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Embedded cards follow the chat host, falling back to the system theme.
|
|
159
|
+
*
|
|
160
|
+
* The App's type rules: Inter 400 body without letter-spacing, 500 for names
|
|
161
|
+
* and headings, 600 only at 16px and 24px; four sizes (12/14/16/24); no
|
|
162
|
+
* uppercase micro-caps; no monospace face. Radii: 6px controls, 8px panels,
|
|
163
|
+
* 12px cards.
|
|
164
|
+
*/
|
|
165
|
+
export const APIOSK_UI_STYLE = `
|
|
166
|
+
${[400, 500, 600].map(FONT).join("\n")}
|
|
167
|
+
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;${LIGHT_TOKENS}}
|
|
168
|
+
:root[data-theme=dark]{${DARK_TOKENS}}
|
|
169
|
+
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){${DARK_TOKENS}}}
|
|
170
|
+
*{box-sizing:border-box}body{margin:0;padding:12px;background:transparent;color:var(--foreground);font-size:14px;line-height:20px;font-weight:400;font-feature-settings:normal;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility}
|
|
171
|
+
h1,h2,h3,h4{color:var(--heading);font-weight:500;margin:0}
|
|
172
|
+
pre,code{font:inherit}
|
|
173
|
+
::selection{background:var(--accent-wash-strong);color:var(--heading)}
|
|
174
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
|
|
175
|
+
.card{border:1px solid var(--border-2);border-radius:12px;padding:16px;background:var(--card);color:var(--foreground)}
|
|
176
|
+
h2{font-size:16px;line-height:24px;font-weight:600;letter-spacing:-.011em}
|
|
177
|
+
.meta,.hint,.status{font-size:12px;line-height:16px;color:var(--muted)}
|
|
178
|
+
.status{margin-top:12px;min-height:16px}.status.error{color:var(--danger-fg)}.status.ok{color:var(--success-fg)}
|
|
179
|
+
button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;border:1px solid var(--border-2);border-radius:6px;padding:0 12px;background:var(--button-secondary-bg);color:var(--button-secondary-fg);font:inherit;font-size:14px;line-height:20px;font-weight:500;cursor:pointer;transition:background-color .15s,color .15s}
|
|
180
|
+
button:hover{background:var(--secondary)}
|
|
181
|
+
.primary{border-color:transparent;background:var(--accent);color:var(--accent-fg)}.primary:hover{background:var(--button-primary-bg-hover)}
|
|
182
|
+
button:disabled{opacity:.5;cursor:not-allowed}
|
|
183
|
+
.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}
|
|
184
|
+
.hidden{display:none}
|
|
185
|
+
`;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// /.well-known/apiosk-routes.json — a served page, not a tool.
|
|
2
|
+
//
|
|
3
|
+
// This is a crawler-facing surface, and it is how agents that never install an
|
|
4
|
+
// MCP server find out that a route exists at all. It reshapes the gateway's own
|
|
5
|
+
// /.well-known/x402 document, which is the canonical one; nothing here is a
|
|
6
|
+
// second source of truth, and nothing here is a capability an agent can call.
|
|
7
|
+
//
|
|
8
|
+
// It survived the earlier cut-to-five refactor for the same reason
|
|
9
|
+
// src/settlement-disclosure.mjs did: it is a page this server serves, not a
|
|
10
|
+
// choice an agent has to make.
|
|
11
|
+
|
|
12
|
+
import { resolveGatewayBaseUrl } from "./gateway-client.mjs";
|
|
13
|
+
import { trimString } from "./tool-result.mjs";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MCP_PUBLIC_BASE_URL = "https://mcp.apiosk.com";
|
|
16
|
+
const CACHE_TTL_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pages the gateway's discovery document is walked in. The gateway caps
|
|
20
|
+
* `perPage` at 100; asking for it keeps a full catalogue walk to a dozen or so
|
|
21
|
+
* requests instead of one per fifteen resources.
|
|
22
|
+
*/
|
|
23
|
+
const PAGE_SIZE = 100;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Safety bound on the walk. At the cap this is 5,000 resources — well past the
|
|
27
|
+
* live catalogue — and it stops a gateway bug that always advertises a `next`
|
|
28
|
+
* from turning this endpoint into an infinite loop.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_PAGES = 50;
|
|
31
|
+
|
|
32
|
+
const CAIP2_NETWORK_NAMES = {
|
|
33
|
+
"eip155:8453": "base",
|
|
34
|
+
"eip155:137": "polygon",
|
|
35
|
+
"eip155:42161": "arbitrum",
|
|
36
|
+
"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "solana",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let cache = null;
|
|
40
|
+
|
|
41
|
+
export function clearWellKnownRoutesCache() {
|
|
42
|
+
cache = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function friendlyNetworkName(network) {
|
|
46
|
+
const value = trimString(network);
|
|
47
|
+
if (!value) return "base";
|
|
48
|
+
if (CAIP2_NETWORK_NAMES[value]) return CAIP2_NETWORK_NAMES[value];
|
|
49
|
+
if (value.startsWith("solana:")) return "solana";
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveMcpPublicBaseUrl(env = process.env) {
|
|
54
|
+
return (trimString(env.APIOSK_MCP_PUBLIC_BASE_URL) || DEFAULT_MCP_PUBLIC_BASE_URL).replace(/\/+$/, "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function reshapeDiscoveryItems(wellKnownDocument) {
|
|
58
|
+
const items = Array.isArray(wellKnownDocument?.items) ? wellKnownDocument.items : [];
|
|
59
|
+
return items.map((item) => {
|
|
60
|
+
const offer = Array.isArray(item.accepts) ? item.accepts[0] : null;
|
|
61
|
+
const metadata = item.metadata && typeof item.metadata === "object" ? item.metadata : {};
|
|
62
|
+
// x402 v2 calls the atomic amount `amount`; v1 called it
|
|
63
|
+
// `maxAmountRequired`. Both are USDC 6-decimal atomic units.
|
|
64
|
+
const atomic = Number.parseInt(trimString(offer?.amount ?? offer?.maxAmountRequired), 10);
|
|
65
|
+
return {
|
|
66
|
+
name: metadata.name || metadata.api || undefined,
|
|
67
|
+
description: metadata.description || undefined,
|
|
68
|
+
url: item.resource,
|
|
69
|
+
method: metadata.method || "GET",
|
|
70
|
+
price: Number.isFinite(atomic)
|
|
71
|
+
? (atomic / 1e6).toFixed(6).replace(/0+$/, "").replace(/\.$/, "")
|
|
72
|
+
: undefined,
|
|
73
|
+
currency: "USDC",
|
|
74
|
+
network: friendlyNetworkName(offer?.network),
|
|
75
|
+
pay_to: offer?.payTo || undefined,
|
|
76
|
+
x402_version: item.x402Version ?? wellKnownDocument?.x402Version ?? 1,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every item in the gateway's x402 document, following `links.next` to the end.
|
|
83
|
+
*
|
|
84
|
+
* The document is paginated (a single response was 7.3 MB and unreadable), so
|
|
85
|
+
* an endpoint that promises an index of EVERY paid route has to walk it. A
|
|
86
|
+
* document without `links.next` is treated as complete, which is also what an
|
|
87
|
+
* older, unpaginated gateway produces.
|
|
88
|
+
*/
|
|
89
|
+
async function fetchAllItems(gateway, doFetch) {
|
|
90
|
+
// `include=all`: the gateway document publishes managed listings by default,
|
|
91
|
+
// and this endpoint promises every paid route, federated ones included.
|
|
92
|
+
let url = `${gateway}/.well-known/x402?perPage=${PAGE_SIZE}&include=all`;
|
|
93
|
+
let first = null;
|
|
94
|
+
const items = [];
|
|
95
|
+
|
|
96
|
+
for (let page = 0; page < MAX_PAGES && url; page += 1) {
|
|
97
|
+
const response = await doFetch(url, { headers: { accept: "application/json" } });
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
throw new Error(`Gateway discovery document unavailable (HTTP ${response.status}).`);
|
|
100
|
+
}
|
|
101
|
+
const document = await response.json();
|
|
102
|
+
first = first || document;
|
|
103
|
+
if (Array.isArray(document.items)) items.push(...document.items);
|
|
104
|
+
|
|
105
|
+
const next = document?.links?.next;
|
|
106
|
+
url = typeof next === "string" && next ? next : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { document: first || {}, items };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function buildDiscoveryDocument({ env = process.env, fetchImpl } = {}) {
|
|
113
|
+
if (cache && cache.expiresAt > Date.now()) return cache.document;
|
|
114
|
+
|
|
115
|
+
const gateway = resolveGatewayBaseUrl(env);
|
|
116
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
117
|
+
const { document: wellKnown, items } = await fetchAllItems(gateway, doFetch);
|
|
118
|
+
|
|
119
|
+
const routes = reshapeDiscoveryItems({ ...wellKnown, items });
|
|
120
|
+
const document = {
|
|
121
|
+
name: "Apiosk paid API routes",
|
|
122
|
+
description:
|
|
123
|
+
"Machine-readable index of paid x402 routes published through Apiosk. Each route returns 402 Payment Required with an x402 offer until paid in USDC.",
|
|
124
|
+
generated_from: `${gateway}/.well-known/x402`,
|
|
125
|
+
mcp_endpoint: `${resolveMcpPublicBaseUrl(env)}/mcp`,
|
|
126
|
+
count: routes.length,
|
|
127
|
+
routes,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
cache = { document, expiresAt: Date.now() + CACHE_TTL_MS };
|
|
131
|
+
return document;
|
|
132
|
+
}
|