@stackable-labs/mcp-app-extension 0.2.1 → 0.3.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/dist/index.js +191 -182
- package/dist/server.js +3985 -0
- package/package.json +11 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile } from 'fs/promises';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { homedir } from 'os';
|
|
5
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
4
|
import { IDENTITY_EVENTS, ACTIVITY_EVENTS, SURFACE_TARGETS, PERMISSIONS, CAPABILITY_PERMISSION_MAP, ALLOWED_ICONS, UI_TAGS, UI_TAG_ATTRIBUTES, tagToComponentName } from '@stackable-labs/sdk-extension-contracts';
|
|
8
5
|
import { z } from 'zod';
|
|
9
6
|
|
|
@@ -3780,200 +3777,212 @@ var STANDALONE_CLIENT = {
|
|
|
3780
3777
|
var package_default = {
|
|
3781
3778
|
version: "0.0.0"};
|
|
3782
3779
|
|
|
3783
|
-
// src/
|
|
3780
|
+
// src/server.ts
|
|
3784
3781
|
var MCP_CLIENT_NAME = STANDALONE_CLIENT.MCP;
|
|
3785
3782
|
var DEFAULT_ADMIN_API_URL = "https://api-use1.stackablelabs.io/admin";
|
|
3786
|
-
var
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
}
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3783
|
+
var textContent = (text) => ({ content: [{ type: "text", text }] });
|
|
3784
|
+
var errorContent = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
3785
|
+
var createMcpServer = (options = {}) => {
|
|
3786
|
+
const server2 = new McpServer({
|
|
3787
|
+
name: "stackable-extension-dev",
|
|
3788
|
+
version: package_default.version
|
|
3789
|
+
});
|
|
3790
|
+
const getAuthToken = async () => {
|
|
3791
|
+
if (options.authContext?.token) {
|
|
3792
|
+
return options.authContext.token;
|
|
3793
|
+
}
|
|
3794
|
+
const { readFile } = await import('fs/promises');
|
|
3795
|
+
const { join } = await import('path');
|
|
3796
|
+
const { homedir } = await import('os');
|
|
3797
|
+
const authFile = join(homedir(), ".stackable", "auth.json");
|
|
3798
|
+
let state;
|
|
3797
3799
|
try {
|
|
3798
|
-
const
|
|
3799
|
-
|
|
3800
|
-
|
|
3800
|
+
const content = await readFile(authFile, "utf8");
|
|
3801
|
+
state = JSON.parse(content);
|
|
3802
|
+
} catch {
|
|
3803
|
+
throw new Error("Not authenticated. Run 'stackable-app-extension auth login' first.");
|
|
3804
|
+
}
|
|
3805
|
+
const [, payload] = state.token.split(".");
|
|
3806
|
+
if (payload) {
|
|
3807
|
+
try {
|
|
3808
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
3809
|
+
if (decoded.exp && Date.now() >= decoded.exp * 1e3) {
|
|
3810
|
+
throw new Error("Session expired. Run 'stackable-app-extension auth login' to re-authenticate.");
|
|
3811
|
+
}
|
|
3812
|
+
} catch (err) {
|
|
3813
|
+
if (err instanceof Error && err.message.includes("expired")) {
|
|
3814
|
+
throw err;
|
|
3815
|
+
}
|
|
3801
3816
|
}
|
|
3817
|
+
}
|
|
3818
|
+
return state.token;
|
|
3819
|
+
};
|
|
3820
|
+
const authHeaders = (token) => ({
|
|
3821
|
+
authorization: `Bearer ${token}`,
|
|
3822
|
+
"content-type": "application/json",
|
|
3823
|
+
"x-client-name": MCP_CLIENT_NAME
|
|
3824
|
+
});
|
|
3825
|
+
const callApi = async (path) => {
|
|
3826
|
+
const token = await getAuthToken();
|
|
3827
|
+
const baseUrl = process.env.ADMIN_API_BASE_URL ?? DEFAULT_ADMIN_API_URL;
|
|
3828
|
+
const res = await fetch(`${baseUrl}${path}`, { headers: authHeaders(token) });
|
|
3829
|
+
if (!res.ok) {
|
|
3830
|
+
return errorContent(`API error: ${res.status} ${res.statusText}`);
|
|
3831
|
+
}
|
|
3832
|
+
const data = await res.json();
|
|
3833
|
+
return textContent(JSON.stringify(data, null, 2));
|
|
3834
|
+
};
|
|
3835
|
+
const withAuth = async (fn) => {
|
|
3836
|
+
try {
|
|
3837
|
+
return await fn();
|
|
3802
3838
|
} catch (err) {
|
|
3803
|
-
|
|
3804
|
-
throw err;
|
|
3805
|
-
}
|
|
3839
|
+
return errorContent(err.message);
|
|
3806
3840
|
}
|
|
3841
|
+
};
|
|
3842
|
+
const registrySkills = listSkills("registry");
|
|
3843
|
+
for (const skill of registrySkills) {
|
|
3844
|
+
server2.registerResource(
|
|
3845
|
+
skill.id,
|
|
3846
|
+
`sdk://${skill.id}`,
|
|
3847
|
+
{ description: skill.description, mimeType: "text/markdown" },
|
|
3848
|
+
async (uri) => ({
|
|
3849
|
+
contents: [{ uri: uri.href, text: getSkillBody(skill), mimeType: "text/markdown" }]
|
|
3850
|
+
})
|
|
3851
|
+
);
|
|
3807
3852
|
}
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
})
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
if (!res.ok) {
|
|
3822
|
-
return errorContent(`API error: ${res.status} ${res.statusText}`);
|
|
3823
|
-
}
|
|
3824
|
-
const data = await res.json();
|
|
3825
|
-
return textContent(JSON.stringify(data, null, 2));
|
|
3826
|
-
};
|
|
3827
|
-
var withAuth = async (fn) => {
|
|
3828
|
-
try {
|
|
3829
|
-
return await fn();
|
|
3830
|
-
} catch (err) {
|
|
3831
|
-
return errorContent(err.message);
|
|
3832
|
-
}
|
|
3833
|
-
};
|
|
3834
|
-
var server = new McpServer({
|
|
3835
|
-
name: "stackable-extension-dev",
|
|
3836
|
-
version: package_default.version
|
|
3837
|
-
});
|
|
3838
|
-
var registrySkills = listSkills("registry");
|
|
3839
|
-
for (const skill of registrySkills) {
|
|
3840
|
-
server.registerResource(
|
|
3841
|
-
skill.id,
|
|
3842
|
-
`sdk://${skill.id}`,
|
|
3843
|
-
{ description: skill.description, mimeType: "text/markdown" },
|
|
3844
|
-
async (uri) => ({
|
|
3845
|
-
contents: [{ uri: uri.href, text: getSkillBody(skill), mimeType: "text/markdown" }]
|
|
3846
|
-
})
|
|
3847
|
-
);
|
|
3848
|
-
}
|
|
3849
|
-
server.registerTool("list_skills", {
|
|
3850
|
-
description: "List all available SDK skills with descriptions"
|
|
3851
|
-
}, async () => textContent(JSON.stringify(registrySkills.map(getSkillMetadata), null, 2)));
|
|
3852
|
-
server.registerTool("lookup_skill", {
|
|
3853
|
-
description: "Look up a specific SDK skill by ID or search by keyword. Returns full skill content for ID lookup, or matching skill metadata for keyword search.",
|
|
3854
|
-
inputSchema: { id: z.string().optional().describe('Exact skill ID (e.g. "capabilities", "patterns")'), query: z.string().optional().describe("Search keywords to find relevant skills") }
|
|
3855
|
-
}, async ({ id, query }) => {
|
|
3856
|
-
if (id) {
|
|
3857
|
-
const body = lookupSkill(registrySkills, id);
|
|
3858
|
-
if (!body) {
|
|
3859
|
-
return errorContent(`No skill found with ID "${id}". Use list_skills to see available IDs.`);
|
|
3853
|
+
server2.registerTool("list_skills", {
|
|
3854
|
+
description: "List all available SDK skills with descriptions"
|
|
3855
|
+
}, async () => textContent(JSON.stringify(registrySkills.map(getSkillMetadata), null, 2)));
|
|
3856
|
+
server2.registerTool("lookup_skill", {
|
|
3857
|
+
description: "Look up a specific SDK skill by ID or search by keyword. Returns full skill content for ID lookup, or matching skill metadata for keyword search.",
|
|
3858
|
+
inputSchema: { id: z.string().optional().describe('Exact skill ID (e.g. "capabilities", "patterns")'), query: z.string().optional().describe("Search keywords to find relevant skills") }
|
|
3859
|
+
}, async ({ id, query }) => {
|
|
3860
|
+
if (id) {
|
|
3861
|
+
const body = lookupSkill(registrySkills, id);
|
|
3862
|
+
if (!body) {
|
|
3863
|
+
return errorContent(`No skill found with ID "${id}". Use list_skills to see available IDs.`);
|
|
3864
|
+
}
|
|
3865
|
+
return textContent(body);
|
|
3860
3866
|
}
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
return
|
|
3867
|
+
if (query) {
|
|
3868
|
+
const matches = findRelevantSkills(registrySkills, query);
|
|
3869
|
+
if (matches.length === 0) {
|
|
3870
|
+
return errorContent(`No skills matched "${query}". Use list_skills to see available skills.`);
|
|
3871
|
+
}
|
|
3872
|
+
return textContent(JSON.stringify(matches.map(getSkillMetadata), null, 2));
|
|
3867
3873
|
}
|
|
3868
|
-
return
|
|
3869
|
-
}
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
errors.push(`Invalid permission "${perm}". Valid permissions: ${PERMISSIONS.join(", ")}`);
|
|
3874
|
+
return errorContent('Provide either "id" or "query" parameter.');
|
|
3875
|
+
});
|
|
3876
|
+
server2.registerTool("validate_manifest", {
|
|
3877
|
+
description: "Validate an extension manifest.json. Checks required fields, valid permissions, targets, and allowedDomains consistency with permissions.",
|
|
3878
|
+
inputSchema: { manifestContent: z.string().describe("The full manifest.json content as a string") }
|
|
3879
|
+
}, async ({ manifestContent }) => {
|
|
3880
|
+
const errors = [];
|
|
3881
|
+
let manifest;
|
|
3882
|
+
try {
|
|
3883
|
+
manifest = JSON.parse(manifestContent);
|
|
3884
|
+
} catch {
|
|
3885
|
+
return errorContent("Invalid JSON: could not parse manifest content.");
|
|
3886
|
+
}
|
|
3887
|
+
if (!manifest.name || typeof manifest.name !== "string") {
|
|
3888
|
+
errors.push('Missing or invalid "name" field (must be a non-empty string).');
|
|
3889
|
+
}
|
|
3890
|
+
if (!manifest.version || typeof manifest.version !== "string") {
|
|
3891
|
+
errors.push('Missing or invalid "version" field (must be a non-empty string).');
|
|
3892
|
+
}
|
|
3893
|
+
if (!Array.isArray(manifest.targets) || manifest.targets.length === 0) {
|
|
3894
|
+
errors.push('Missing or empty "targets" array. At least one surface target is required.');
|
|
3895
|
+
}
|
|
3896
|
+
if (!Array.isArray(manifest.permissions)) {
|
|
3897
|
+
errors.push('Missing "permissions" array.');
|
|
3898
|
+
} else {
|
|
3899
|
+
const validPermissions = new Set(PERMISSIONS);
|
|
3900
|
+
for (const perm of manifest.permissions) {
|
|
3901
|
+
if (!validPermissions.has(perm)) {
|
|
3902
|
+
errors.push(`Invalid permission "${perm}". Valid permissions: ${PERMISSIONS.join(", ")}`);
|
|
3903
|
+
}
|
|
3899
3904
|
}
|
|
3900
3905
|
}
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
return errorContent(`Manifest validation failed:
|
|
3906
|
+
if (!Array.isArray(manifest.allowedDomains)) {
|
|
3907
|
+
errors.push('Missing "allowedDomains" array. Use an empty array if no external API calls are needed.');
|
|
3908
|
+
}
|
|
3909
|
+
const permissions = manifest.permissions ?? [];
|
|
3910
|
+
if (permissions.includes("data:fetch") && Array.isArray(manifest.allowedDomains) && manifest.allowedDomains.length === 0) {
|
|
3911
|
+
errors.push('"data:fetch" permission declared but "allowedDomains" is empty. Add the domains your extension needs to fetch from.');
|
|
3912
|
+
}
|
|
3913
|
+
if (errors.length === 0) {
|
|
3914
|
+
return textContent("Manifest is valid.");
|
|
3915
|
+
}
|
|
3916
|
+
return errorContent(`Manifest validation failed:
|
|
3913
3917
|
${errors.map((e) => `- ${e}`).join("\n")}`);
|
|
3914
|
-
});
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
}
|
|
3921
|
-
}, async ({ manifestContent, sourceFiles }) => {
|
|
3922
|
-
let manifest;
|
|
3923
|
-
try {
|
|
3924
|
-
manifest = JSON.parse(manifestContent);
|
|
3925
|
-
} catch {
|
|
3926
|
-
return errorContent("Invalid JSON: could not parse manifest content.");
|
|
3927
|
-
}
|
|
3928
|
-
const declaredPermissions = new Set(manifest.permissions ?? []);
|
|
3929
|
-
const usedPermissions = /* @__PURE__ */ new Set();
|
|
3930
|
-
const allSource = Object.values(sourceFiles).join("\n");
|
|
3931
|
-
for (const [capability, permission] of Object.entries(CAPABILITY_PERMISSION_MAP)) {
|
|
3932
|
-
if (allSource.includes(capability)) {
|
|
3933
|
-
usedPermissions.add(permission);
|
|
3918
|
+
});
|
|
3919
|
+
server2.registerTool("validate_permissions", {
|
|
3920
|
+
description: "Static analysis: match declared manifest permissions against actual capability and event hook usage in source files. Detects unused permissions and missing permissions.",
|
|
3921
|
+
inputSchema: {
|
|
3922
|
+
manifestContent: z.string().describe("The full manifest.json content as a string"),
|
|
3923
|
+
sourceFiles: z.record(z.string(), z.string()).describe("Map of filename \u2192 source content for all extension source files")
|
|
3934
3924
|
}
|
|
3935
|
-
}
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
for (const [hook, permission] of Object.entries(eventHookMap)) {
|
|
3942
|
-
if (allSource.includes(hook)) {
|
|
3943
|
-
usedPermissions.add(permission);
|
|
3925
|
+
}, async ({ manifestContent, sourceFiles }) => {
|
|
3926
|
+
let manifest;
|
|
3927
|
+
try {
|
|
3928
|
+
manifest = JSON.parse(manifestContent);
|
|
3929
|
+
} catch {
|
|
3930
|
+
return errorContent("Invalid JSON: could not parse manifest content.");
|
|
3944
3931
|
}
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3932
|
+
const declaredPermissions = new Set(manifest.permissions ?? []);
|
|
3933
|
+
const usedPermissions = /* @__PURE__ */ new Set();
|
|
3934
|
+
const allSource = Object.values(sourceFiles).join("\n");
|
|
3935
|
+
for (const [capability, permission] of Object.entries(CAPABILITY_PERMISSION_MAP)) {
|
|
3936
|
+
if (allSource.includes(capability)) {
|
|
3937
|
+
usedPermissions.add(permission);
|
|
3938
|
+
}
|
|
3950
3939
|
}
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3940
|
+
const eventHookMap = {
|
|
3941
|
+
useIdentityEvent: "events:identity",
|
|
3942
|
+
useMessagingEvent: "events:messaging",
|
|
3943
|
+
useActivityEvent: "events:activity"
|
|
3944
|
+
};
|
|
3945
|
+
for (const [hook, permission] of Object.entries(eventHookMap)) {
|
|
3946
|
+
if (allSource.includes(hook)) {
|
|
3947
|
+
usedPermissions.add(permission);
|
|
3948
|
+
}
|
|
3955
3949
|
}
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3950
|
+
const issues = [];
|
|
3951
|
+
for (const perm of declaredPermissions) {
|
|
3952
|
+
if (!usedPermissions.has(perm)) {
|
|
3953
|
+
issues.push(`Unused permission "${perm}" \u2014 declared in manifest but no matching capability/hook usage found in source.`);
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
for (const perm of usedPermissions) {
|
|
3957
|
+
if (!declaredPermissions.has(perm)) {
|
|
3958
|
+
issues.push(`Missing permission "${perm}" \u2014 capability/hook used in source but not declared in manifest.`);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
if (issues.length === 0) {
|
|
3962
|
+
return textContent("Permissions are consistent with source code usage.");
|
|
3963
|
+
}
|
|
3964
|
+
return textContent(`Permission analysis:
|
|
3961
3965
|
${issues.map((i) => `- ${i}`).join("\n")}`);
|
|
3962
|
-
});
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
}, async () => withAuth(() => callApi("/app-extension")));
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
}, async ({ appId }) => withAuth(() => callApi(`/app-extension/${appId}/extensions`)));
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
}, async ({ appId, extensionId }) => withAuth(() => callApi(`/app-extension/${appId}/extensions/${extensionId}`)));
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
}, async ({ appId }) => withAuth(() => callApi(`/app-extension/${appId}/instances`)));
|
|
3966
|
+
});
|
|
3967
|
+
server2.registerTool("list_apps", {
|
|
3968
|
+
description: "List available apps for development with IDs, names, and targets. Requires CLI auth."
|
|
3969
|
+
}, async () => withAuth(() => callApi("/app-extension")));
|
|
3970
|
+
server2.registerTool("list_extensions", {
|
|
3971
|
+
description: "List extensions under an app with status, version, and bundle URL. Requires CLI auth.",
|
|
3972
|
+
inputSchema: { appId: z.string().describe("App ID") }
|
|
3973
|
+
}, async ({ appId }) => withAuth(() => callApi(`/app-extension/${appId}/extensions`)));
|
|
3974
|
+
server2.registerTool("get_extension", {
|
|
3975
|
+
description: "Get extension detail including manifest, permissions, and targets. Requires CLI auth.",
|
|
3976
|
+
inputSchema: { appId: z.string().describe("App ID"), extensionId: z.string().describe("Extension ID") }
|
|
3977
|
+
}, async ({ appId, extensionId }) => withAuth(() => callApi(`/app-extension/${appId}/extensions/${extensionId}`)));
|
|
3978
|
+
server2.registerTool("list_instances", {
|
|
3979
|
+
description: "List instances under an app. Requires CLI auth.",
|
|
3980
|
+
inputSchema: { appId: z.string().describe("App ID") }
|
|
3981
|
+
}, async ({ appId }) => withAuth(() => callApi(`/app-extension/${appId}/instances`)));
|
|
3982
|
+
return server2;
|
|
3983
|
+
};
|
|
3984
|
+
|
|
3985
|
+
// src/index.ts
|
|
3986
|
+
var server = createMcpServer();
|
|
3978
3987
|
var transport = new StdioServerTransport();
|
|
3979
3988
|
await server.connect(transport);
|