@aexol/spectral 0.2.5 → 0.2.7
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/dist/cli.js +10 -47
- package/dist/mcp/agent-dir.js +18 -0
- package/dist/mcp/app-bridge.bundle.js +67 -0
- package/dist/mcp/commands.js +263 -0
- package/dist/mcp/config.js +532 -0
- package/dist/mcp/consent-manager.js +59 -0
- package/dist/mcp/direct-tools.js +354 -0
- package/dist/mcp/errors.js +165 -0
- package/dist/mcp/glimpse-ui.js +67 -0
- package/dist/mcp/host-html-template.js +412 -0
- package/dist/mcp/index.js +291 -0
- package/dist/mcp/init.js +280 -0
- package/dist/mcp/lifecycle.js +79 -0
- package/dist/mcp/logger.js +130 -0
- package/dist/mcp/mcp-auth-flow.js +283 -0
- package/dist/mcp/mcp-auth.js +226 -0
- package/dist/mcp/mcp-callback-server.js +225 -0
- package/dist/mcp/mcp-oauth-provider.js +243 -0
- package/dist/mcp/mcp-panel.js +646 -0
- package/dist/mcp/mcp-setup-panel.js +485 -0
- package/dist/mcp/metadata-cache.js +158 -0
- package/dist/mcp/npx-resolver.js +385 -0
- package/dist/mcp/oauth-handler.js +54 -0
- package/dist/mcp/onboarding-state.js +56 -0
- package/dist/mcp/proxy-modes.js +714 -0
- package/dist/mcp/resource-tools.js +14 -0
- package/dist/mcp/sampling-handler.js +206 -0
- package/dist/mcp/server-manager.js +301 -0
- package/dist/mcp/state.js +1 -0
- package/dist/mcp/tool-metadata.js +128 -0
- package/dist/mcp/tool-registrar.js +43 -0
- package/dist/mcp/types.js +93 -0
- package/dist/mcp/ui-resource-handler.js +113 -0
- package/dist/mcp/ui-server.js +522 -0
- package/dist/mcp/ui-session.js +306 -0
- package/dist/mcp/ui-stream-types.js +58 -0
- package/dist/mcp/utils.js +104 -0
- package/dist/mcp/vitest.config.js +13 -0
- package/dist/server/pi-bridge.js +9 -30
- package/package.json +6 -3
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Re-export stream types from the shared lightweight module.
|
|
2
|
+
// This allows the example package to import stream schemas without pulling the full types.ts dependency graph.
|
|
3
|
+
export { UI_STREAM_HOST_CONTEXT_KEY, UI_STREAM_REQUEST_META_KEY, UI_STREAM_RESULT_PATCH_METHOD, SERVER_STREAM_RESULT_PATCH_METHOD, UI_STREAM_STRUCTURED_CONTENT_KEY, uiStreamModeSchema, visualizationStreamPhaseSchema, visualizationStreamFrameTypeSchema, visualizationStreamStatusSchema, uiStreamHostContextSchema, visualizationStreamEnvelopeSchema, uiStreamCallToolResultSchema, uiStreamResultPatchNotificationSchema, serverStreamResultPatchNotificationSchema, getUiStreamHostContext, getVisualizationStreamEnvelope, } from "./ui-stream-types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Extract prompt text from either legacy MCP UI message shapes or native AppBridge user messages.
|
|
6
|
+
*/
|
|
7
|
+
export function extractUiPromptText(params) {
|
|
8
|
+
if (params.type === "prompt" || params.prompt) {
|
|
9
|
+
const prompt = params.prompt ?? String(params.message ?? "");
|
|
10
|
+
return prompt || undefined;
|
|
11
|
+
}
|
|
12
|
+
if (params.role === "user" && Array.isArray(params.content)) {
|
|
13
|
+
const text = params.content
|
|
14
|
+
.map((block) => (block && typeof block === "object" && "text" in block ? String(block.text ?? "") : ""))
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.join("\n\n");
|
|
17
|
+
return text || undefined;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse a canonical named UI handoff encoded as `intent\n{json}`.
|
|
23
|
+
*/
|
|
24
|
+
export function parseUiPromptHandoff(prompt) {
|
|
25
|
+
const newlineIndex = prompt.indexOf("\n");
|
|
26
|
+
if (newlineIndex <= 0) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const intent = prompt.slice(0, newlineIndex).trim();
|
|
30
|
+
const payloadText = prompt.slice(newlineIndex + 1).trim();
|
|
31
|
+
if (!intent || !payloadText) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(intent)) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(payloadText);
|
|
39
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
intent,
|
|
44
|
+
params: parsed,
|
|
45
|
+
raw: prompt,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Get server prefix based on tool prefix mode.
|
|
54
|
+
*/
|
|
55
|
+
export function getServerPrefix(serverName, mode) {
|
|
56
|
+
if (mode === "none")
|
|
57
|
+
return "";
|
|
58
|
+
if (mode === "short") {
|
|
59
|
+
let short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
|
|
60
|
+
if (!short)
|
|
61
|
+
short = "mcp";
|
|
62
|
+
return short;
|
|
63
|
+
}
|
|
64
|
+
return serverName.replace(/-/g, "_");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Format a tool name with server prefix.
|
|
68
|
+
*/
|
|
69
|
+
export function formatToolName(toolName, serverName, prefix) {
|
|
70
|
+
const p = getServerPrefix(serverName, prefix);
|
|
71
|
+
return p ? `${p}_${toolName}` : toolName;
|
|
72
|
+
}
|
|
73
|
+
function normalizeToolName(value) {
|
|
74
|
+
return value.replace(/-/g, "_");
|
|
75
|
+
}
|
|
76
|
+
export function isToolExcluded(toolName, serverName, prefix, excludeTools) {
|
|
77
|
+
if (!Array.isArray(excludeTools) || excludeTools.length === 0)
|
|
78
|
+
return false;
|
|
79
|
+
const candidates = new Set([
|
|
80
|
+
normalizeToolName(toolName),
|
|
81
|
+
normalizeToolName(formatToolName(toolName, serverName, prefix)),
|
|
82
|
+
normalizeToolName(formatToolName(toolName, serverName, "server")),
|
|
83
|
+
normalizeToolName(formatToolName(toolName, serverName, "short")),
|
|
84
|
+
]);
|
|
85
|
+
for (const excluded of excludeTools) {
|
|
86
|
+
if (typeof excluded !== "string")
|
|
87
|
+
continue;
|
|
88
|
+
if (candidates.has(normalizeToolName(excluded))) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
|
|
2
|
+
import { ResourceFetchError, ResourceParseError } from "./errors.js";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
export class UiResourceHandler {
|
|
5
|
+
manager;
|
|
6
|
+
log = logger.child({ component: "UiResourceHandler" });
|
|
7
|
+
constructor(manager) {
|
|
8
|
+
this.manager = manager;
|
|
9
|
+
}
|
|
10
|
+
async readUiResource(serverName, uri) {
|
|
11
|
+
const log = this.log.child({ server: serverName, uri });
|
|
12
|
+
if (!uri.startsWith("ui://")) {
|
|
13
|
+
throw new ResourceParseError(uri, "URI must start with ui://", { server: serverName });
|
|
14
|
+
}
|
|
15
|
+
log.debug("Fetching UI resource");
|
|
16
|
+
let result;
|
|
17
|
+
try {
|
|
18
|
+
result = await this.manager.readResource(serverName, uri);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
log.error("Failed to read resource", error instanceof Error ? error : undefined);
|
|
23
|
+
throw new ResourceFetchError(uri, message, {
|
|
24
|
+
server: serverName,
|
|
25
|
+
cause: error instanceof Error ? error : undefined,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const content = selectContent(result, uri);
|
|
29
|
+
const mimeType = content.mimeType;
|
|
30
|
+
if (mimeType && !isHtmlMimeType(mimeType)) {
|
|
31
|
+
log.warn("Unsupported MIME type", { mimeType });
|
|
32
|
+
throw new ResourceParseError(uri, `unsupported MIME type "${mimeType}" (expected text/html or ${RESOURCE_MIME_TYPE})`, { server: serverName, mimeType });
|
|
33
|
+
}
|
|
34
|
+
const html = toHtml(content);
|
|
35
|
+
if (!html.trim()) {
|
|
36
|
+
log.warn("Resource content is empty");
|
|
37
|
+
throw new ResourceParseError(uri, "content is empty", { server: serverName });
|
|
38
|
+
}
|
|
39
|
+
const contentMeta = extractUiMeta(content._meta);
|
|
40
|
+
const listMeta = extractUiMeta(this.getListResourceMeta(serverName, uri));
|
|
41
|
+
log.debug("Resource loaded successfully", {
|
|
42
|
+
contentLength: html.length,
|
|
43
|
+
hasCsp: !!contentMeta.csp || !!listMeta.csp,
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
uri: content.uri ?? uri,
|
|
47
|
+
html,
|
|
48
|
+
mimeType: mimeType ?? RESOURCE_MIME_TYPE,
|
|
49
|
+
meta: {
|
|
50
|
+
csp: contentMeta.csp ?? listMeta.csp,
|
|
51
|
+
permissions: contentMeta.permissions ?? listMeta.permissions,
|
|
52
|
+
domain: contentMeta.domain ?? listMeta.domain,
|
|
53
|
+
prefersBorder: contentMeta.prefersBorder ?? listMeta.prefersBorder,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
getListResourceMeta(serverName, uri) {
|
|
58
|
+
const connection = this.manager.getConnection(serverName);
|
|
59
|
+
if (!connection?.resources?.length)
|
|
60
|
+
return undefined;
|
|
61
|
+
const resource = connection.resources.find((entry) => entry.uri === uri);
|
|
62
|
+
if (!resource || !resource._meta || typeof resource._meta !== "object")
|
|
63
|
+
return undefined;
|
|
64
|
+
return resource._meta;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function selectContent(result, preferredUri) {
|
|
68
|
+
const contents = (result.contents ?? []);
|
|
69
|
+
if (contents.length === 0) {
|
|
70
|
+
throw new Error(`No contents returned for UI resource: ${preferredUri}`);
|
|
71
|
+
}
|
|
72
|
+
const byUri = contents.find((content) => content.uri === preferredUri);
|
|
73
|
+
if (byUri)
|
|
74
|
+
return byUri;
|
|
75
|
+
const byHtmlMime = contents.find((content) => content.mimeType && isHtmlMimeType(content.mimeType));
|
|
76
|
+
if (byHtmlMime)
|
|
77
|
+
return byHtmlMime;
|
|
78
|
+
return contents[0];
|
|
79
|
+
}
|
|
80
|
+
function isHtmlMimeType(mimeType) {
|
|
81
|
+
const normalized = mimeType.toLowerCase();
|
|
82
|
+
return normalized.startsWith("text/html") || normalized === RESOURCE_MIME_TYPE.toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
function toHtml(content) {
|
|
85
|
+
if (typeof content.text === "string") {
|
|
86
|
+
return content.text;
|
|
87
|
+
}
|
|
88
|
+
if (typeof content.blob === "string") {
|
|
89
|
+
return Buffer.from(content.blob, "base64").toString("utf-8");
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`UI resource ${content.uri ?? "(unknown)"} did not include text or blob content`);
|
|
92
|
+
}
|
|
93
|
+
function extractUiMeta(meta) {
|
|
94
|
+
if (!meta || typeof meta !== "object")
|
|
95
|
+
return {};
|
|
96
|
+
const ui = meta.ui;
|
|
97
|
+
if (!ui || typeof ui !== "object")
|
|
98
|
+
return {};
|
|
99
|
+
const out = {};
|
|
100
|
+
if (ui.csp && typeof ui.csp === "object") {
|
|
101
|
+
out.csp = ui.csp;
|
|
102
|
+
}
|
|
103
|
+
if (ui.permissions && typeof ui.permissions === "object") {
|
|
104
|
+
out.permissions = ui.permissions;
|
|
105
|
+
}
|
|
106
|
+
if (typeof ui.domain === "string") {
|
|
107
|
+
out.domain = ui.domain;
|
|
108
|
+
}
|
|
109
|
+
if (typeof ui.prefersBorder === "boolean") {
|
|
110
|
+
out.prefersBorder = ui.prefersBorder;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|