@amityco/foundry-mcp 0.1.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.
@@ -0,0 +1,193 @@
1
+ import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
2
+ import { harnessControlsFor } from "./harness.js";
3
+ import { inspectProject } from "./project.js";
4
+ export const resolveRequestTool = {
5
+ name: "resolve_request",
6
+ description: "Resolve a natural-language social.plus integration request into a support level and next tools.",
7
+ inputSchema: {
8
+ type: "object",
9
+ properties: {
10
+ repoPath: { type: "string" },
11
+ request: { type: "string" },
12
+ surfacePath: {
13
+ type: "string",
14
+ description: "Optional app/workspace path inside repoPath, such as apps/web or apps/mobile.",
15
+ },
16
+ },
17
+ required: ["repoPath", "request"],
18
+ additionalProperties: false,
19
+ },
20
+ async call(input) {
21
+ const args = objectInput(input);
22
+ const repoPath = stringField(args, "repoPath");
23
+ const request = stringField(args, "request");
24
+ const inspection = await inspectProject(repoPath, optionalStringField(args, "surfacePath"));
25
+ const outcome = classifyOutcome(request);
26
+ const supportLevel = supportFor(outcome, inspection.platforms);
27
+ return textResult({
28
+ outcome,
29
+ supportLevel,
30
+ surface: inspection.selectedSurface ? { path: inspection.selectedSurface.path, platforms: inspection.selectedSurface.platforms } : undefined,
31
+ availableSurfaces: inspection.surfaces.map((surface) => ({ path: surface.path, platforms: surface.platforms })),
32
+ targetPlatforms: inspection.platforms,
33
+ nextTools: nextToolsFor(outcome, supportLevel),
34
+ harness: harnessControlsFor(outcome, inspection.platforms),
35
+ notes: notesFor(outcome, supportLevel, inspection.platforms),
36
+ });
37
+ },
38
+ };
39
+ export const suggestPatchTool = {
40
+ name: "suggest_patch",
41
+ description: "Deprecated compatibility tool. Prefer plan_integration for grounded implementation planning. This tool does not write files.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ repoPath: { type: "string" },
46
+ request: { type: "string" },
47
+ },
48
+ required: ["repoPath", "request"],
49
+ additionalProperties: false,
50
+ },
51
+ async call(input) {
52
+ const args = objectInput(input);
53
+ const repoPath = stringField(args, "repoPath");
54
+ const request = stringField(args, "request");
55
+ const inspection = await inspectProject(repoPath);
56
+ const outcome = classifyOutcome(request);
57
+ return textResult({
58
+ deprecated: true,
59
+ replacement: "plan_integration",
60
+ writes: [],
61
+ outcome,
62
+ targetPlatforms: inspection.platforms,
63
+ plan: planFor(outcome, inspection.platforms),
64
+ harness: harnessControlsFor(outcome, inspection.platforms),
65
+ verification: verificationFor(outcome, inspection.platforms),
66
+ safety: "Read-only v1: no files were modified.",
67
+ });
68
+ },
69
+ };
70
+ function classifyOutcome(request) {
71
+ const normalized = request.toLowerCase();
72
+ if (/\b(push|notification|firebase|fcm|apns)\b/.test(normalized)) {
73
+ return "setup-push";
74
+ }
75
+ if (/\b(live object|live objects|live collection|live collections|realtime collection|real-time collection|observe|observer|subscribe|subscription|unsubscribe|live update|live updates)\b/.test(normalized)) {
76
+ return "setup-live-data";
77
+ }
78
+ if (/\b(social feature|social features|feed|timeline|post list|news feed)\b/.test(normalized)) {
79
+ return "add-feed";
80
+ }
81
+ if (/\b(error|broken|crash|not working|fail|timeout|401|403)\b/.test(normalized)) {
82
+ return "troubleshoot";
83
+ }
84
+ if (/\b(validate|check|correct|setup right|initiali[sz])\b/.test(normalized)) {
85
+ return "validate-setup";
86
+ }
87
+ if (/\b(setup|set up|install|integrate|wire|configure)\b/.test(normalized)) {
88
+ return "setup-sdk";
89
+ }
90
+ return "unknown";
91
+ }
92
+ function supportFor(outcome, platforms) {
93
+ if (outcome === "unknown") {
94
+ return "unsupported";
95
+ }
96
+ if (platforms.length === 0) {
97
+ return "guided";
98
+ }
99
+ if (platforms.some((platform) => ["android", "flutter", "typescript", "react-native"].includes(platform))) {
100
+ return "supported";
101
+ }
102
+ if (platforms.includes("ios")) {
103
+ return "guided";
104
+ }
105
+ return "guided";
106
+ }
107
+ function nextToolsFor(outcome, supportLevel) {
108
+ if (supportLevel === "unsupported") {
109
+ return ["search_docs"];
110
+ }
111
+ if (outcome === "troubleshoot") {
112
+ return ["inspect_project", "search_docs", "get_doc_page", "validate_setup"];
113
+ }
114
+ return ["plan_harness", "plan_integration", "inspect_project", "search_docs", "get_doc_page", "validate_setup", "run_sensors"];
115
+ }
116
+ function notesFor(outcome, supportLevel, platforms) {
117
+ const notes = [];
118
+ if (platforms.length === 0) {
119
+ notes.push("No platform was detected. Ask for the app framework or point repoPath at the project root.");
120
+ }
121
+ if (platforms.includes("ios")) {
122
+ notes.push("iOS should be treated as guided until setup validation rules are expanded.");
123
+ }
124
+ if (supportLevel === "unsupported") {
125
+ notes.push("The request does not map to a known Foundry outcome.");
126
+ }
127
+ if (outcome === "setup-push") {
128
+ notes.push("Push setup usually needs customer-owned Firebase/APNS values; do not invent secrets or config IDs.");
129
+ }
130
+ if (outcome === "setup-live-data") {
131
+ notes.push("Live Object and Live Collection work needs an explicit lifecycle owner so observers can be cleaned up.");
132
+ }
133
+ return notes;
134
+ }
135
+ function planFor(outcome, platforms) {
136
+ const platform = platforms[0] ?? "unknown";
137
+ if (outcome === "setup-sdk" && platform === "android") {
138
+ return [
139
+ { file: "app/build.gradle or app/build.gradle.kts", intent: "Add the social.plus Android SDK dependency." },
140
+ { file: "Application class", intent: "Initialize the SDK once at app startup with API key and region." },
141
+ { file: "Login/auth flow", intent: "Call login after the app has a known user identity." },
142
+ ];
143
+ }
144
+ if (outcome === "setup-sdk" && platform === "flutter") {
145
+ return [
146
+ { file: "pubspec.yaml", intent: "Add the social.plus Flutter dependency." },
147
+ { file: "lib/main.dart", intent: "Ensure Flutter binding is initialized before SDK setup." },
148
+ { file: "Login/auth flow", intent: "Call login after the app has a known user identity." },
149
+ ];
150
+ }
151
+ if (outcome === "add-feed") {
152
+ return [
153
+ { file: "Target screen/component", intent: "Add a feed query and render loading, empty, error, and item states." },
154
+ { file: "Navigation route", intent: "Expose the feed surface from the existing app navigation." },
155
+ ];
156
+ }
157
+ if (outcome === "setup-push") {
158
+ return [
159
+ { file: "Platform notification config", intent: "Wire customer-owned Firebase/APNS configuration." },
160
+ { file: "Login/auth flow", intent: "Register the device after login and unregister on logout." },
161
+ ];
162
+ }
163
+ if (outcome === "setup-live-data") {
164
+ return [
165
+ { file: "Target screen/component/controller", intent: "Observe the Live Object or Live Collection and render loading, empty, error, and data states." },
166
+ { file: "Data source/repository module", intent: "Encapsulate get/query calls and clean up subscriptions when the lifecycle owner ends." },
167
+ ];
168
+ }
169
+ return [{ file: "customer app", intent: "Inspect the app and fetch relevant docs before proposing code changes." }];
170
+ }
171
+ function verificationFor(outcome, platforms) {
172
+ const checks = ["Run the app's normal build or typecheck command."];
173
+ if (outcome === "setup-sdk" || outcome === "validate-setup") {
174
+ checks.push("Confirm SDK setup runs before any social.plus API call.");
175
+ checks.push("Confirm API key and region match the customer's social.plus console project.");
176
+ }
177
+ if (outcome === "add-feed") {
178
+ checks.push("Confirm the feed screen handles loading, empty, error, and data states.");
179
+ checks.push("Confirm the feed query is scoped to the intended global, user, or community feed.");
180
+ }
181
+ if (outcome === "setup-push") {
182
+ checks.push("Confirm device registration happens after login.");
183
+ checks.push("Confirm unregister runs on logout or user switch.");
184
+ }
185
+ if (outcome === "setup-live-data") {
186
+ checks.push("Confirm Live Object/Collection observers clean up on unmount/dispose/disappear.");
187
+ checks.push("Confirm loading and error states are rendered before data is treated as ready.");
188
+ }
189
+ if (platforms.includes("android")) {
190
+ checks.push("For Android, confirm INTERNET permission exists in AndroidManifest.xml.");
191
+ }
192
+ return checks;
193
+ }
@@ -0,0 +1,185 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ import { objectInput, optionalNumberField, optionalStringField, stringField, textResult } from "../types.js";
4
+ import { detectCommandSensors } from "./harness.js";
5
+ import { inspectProject } from "./project.js";
6
+ export const runSensorsTool = {
7
+ name: "run_sensors",
8
+ description: "Run detected project command sensors, such as build/typecheck/test, with a timeout and structured results.",
9
+ inputSchema: {
10
+ type: "object",
11
+ properties: {
12
+ repoPath: {
13
+ type: "string",
14
+ description: "Absolute or relative path to the customer repository root.",
15
+ },
16
+ request: {
17
+ type: "string",
18
+ description: "Natural-language integration request. Used for traceability; command sensors are detected from the project.",
19
+ },
20
+ include: {
21
+ type: "array",
22
+ items: { type: "string" },
23
+ description: "Optional list of sensor names to run. If omitted, all detected command sensors run.",
24
+ },
25
+ timeoutMs: {
26
+ type: "number",
27
+ default: 120000,
28
+ description: "Per-command timeout in milliseconds.",
29
+ },
30
+ dryRun: {
31
+ type: "boolean",
32
+ default: false,
33
+ description: "If true, return detected sensors without executing commands.",
34
+ },
35
+ surfacePath: {
36
+ type: "string",
37
+ description: "Optional app/workspace path inside repoPath, such as apps/web or apps/mobile.",
38
+ },
39
+ },
40
+ required: ["repoPath"],
41
+ additionalProperties: false,
42
+ },
43
+ async call(input) {
44
+ const args = objectInput(input);
45
+ const repoPath = stringField(args, "repoPath");
46
+ const timeoutMs = Math.max(1000, Math.min(optionalNumberField(args, "timeoutMs", 120000), 600000));
47
+ const dryRun = args.dryRun === true;
48
+ const include = stringArrayField(args, "include");
49
+ const root = path.resolve(repoPath);
50
+ const inspection = await inspectProject(root, optionalStringField(args, "surfacePath"));
51
+ const detectedSensors = await detectCommandSensors(inspection.effectiveRoot, inspection.platforms);
52
+ const selectedSensors = include.length > 0 ? detectedSensors.filter((sensor) => include.includes(sensor.name)) : detectedSensors;
53
+ if (dryRun) {
54
+ return textResult({
55
+ status: "dry-run",
56
+ surfacePath: inspection.selectedSurface?.path,
57
+ targetPlatforms: inspection.platforms,
58
+ sensors: selectedSensors,
59
+ });
60
+ }
61
+ const results = [];
62
+ for (const sensor of selectedSensors) {
63
+ results.push(await runSensor(inspection.effectiveRoot, sensor, timeoutMs));
64
+ }
65
+ return textResult({
66
+ status: aggregateStatus(results),
67
+ surfacePath: inspection.selectedSurface?.path,
68
+ targetPlatforms: inspection.platforms,
69
+ results,
70
+ skipped: skippedSensors(detectedSensors, selectedSensors, include),
71
+ });
72
+ },
73
+ };
74
+ async function runSensor(cwd, sensor, timeoutMs) {
75
+ const startedAt = Date.now();
76
+ const [command, ...args] = sensor.command;
77
+ if (!command) {
78
+ return {
79
+ name: sensor.name,
80
+ command: sensor.command,
81
+ status: "skipped",
82
+ reason: "Sensor command is empty.",
83
+ };
84
+ }
85
+ return new Promise((resolve) => {
86
+ let stdout = "";
87
+ let stderr = "";
88
+ let settled = false;
89
+ const child = spawn(command, args, {
90
+ cwd,
91
+ shell: false,
92
+ env: process.env,
93
+ });
94
+ const timeout = setTimeout(() => {
95
+ if (settled) {
96
+ return;
97
+ }
98
+ settled = true;
99
+ child.kill("SIGTERM");
100
+ resolve({
101
+ name: sensor.name,
102
+ command: sensor.command,
103
+ status: "timed-out",
104
+ durationMs: Date.now() - startedAt,
105
+ stdout: truncate(stdout),
106
+ stderr: truncate(stderr),
107
+ reason: `Timed out after ${timeoutMs}ms.`,
108
+ });
109
+ }, timeoutMs);
110
+ child.stdout?.on("data", (chunk) => {
111
+ stdout += chunk.toString("utf8");
112
+ });
113
+ child.stderr?.on("data", (chunk) => {
114
+ stderr += chunk.toString("utf8");
115
+ });
116
+ child.on("error", (error) => {
117
+ if (settled) {
118
+ return;
119
+ }
120
+ settled = true;
121
+ clearTimeout(timeout);
122
+ resolve({
123
+ name: sensor.name,
124
+ command: sensor.command,
125
+ status: "failed",
126
+ durationMs: Date.now() - startedAt,
127
+ stderr: truncate(error.message),
128
+ reason: "Failed to start sensor command.",
129
+ });
130
+ });
131
+ child.on("close", (exitCode) => {
132
+ if (settled) {
133
+ return;
134
+ }
135
+ settled = true;
136
+ clearTimeout(timeout);
137
+ resolve({
138
+ name: sensor.name,
139
+ command: sensor.command,
140
+ status: exitCode === 0 ? "passed" : "failed",
141
+ exitCode,
142
+ durationMs: Date.now() - startedAt,
143
+ stdout: truncate(stdout),
144
+ stderr: truncate(stderr),
145
+ });
146
+ });
147
+ });
148
+ }
149
+ function aggregateStatus(results) {
150
+ if (results.length === 0) {
151
+ return "no-sensors";
152
+ }
153
+ if (results.some((result) => result.status === "timed-out")) {
154
+ return "timed-out";
155
+ }
156
+ if (results.some((result) => result.status === "failed")) {
157
+ return "failed";
158
+ }
159
+ return "passed";
160
+ }
161
+ function skippedSensors(detectedSensors, selectedSensors, include) {
162
+ const selectedNames = new Set(selectedSensors.map((sensor) => sensor.name));
163
+ const skipped = detectedSensors
164
+ .filter((sensor) => !selectedNames.has(sensor.name))
165
+ .map((sensor) => ({ name: sensor.name, reason: "Not selected by include filter." }));
166
+ for (const requestedName of include) {
167
+ if (!detectedSensors.some((sensor) => sensor.name === requestedName)) {
168
+ skipped.push({ name: requestedName, reason: "No detected sensor matched this include name." });
169
+ }
170
+ }
171
+ return skipped;
172
+ }
173
+ function stringArrayField(input, field) {
174
+ const value = input[field];
175
+ if (!Array.isArray(value)) {
176
+ return [];
177
+ }
178
+ return value.filter((item) => typeof item === "string" && item.trim() !== "");
179
+ }
180
+ function truncate(value, maxLength = 8000) {
181
+ if (value.length <= maxLength) {
182
+ return value;
183
+ }
184
+ return `${value.slice(0, maxLength)}\n[truncated ${value.length - maxLength} chars]`;
185
+ }
package/dist/types.js ADDED
@@ -0,0 +1,31 @@
1
+ export function textResult(value) {
2
+ return {
3
+ content: [
4
+ {
5
+ type: "text",
6
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
7
+ },
8
+ ],
9
+ };
10
+ }
11
+ export function objectInput(input) {
12
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
13
+ return {};
14
+ }
15
+ return input;
16
+ }
17
+ export function stringField(input, field) {
18
+ const value = input[field];
19
+ if (typeof value !== "string" || value.trim() === "") {
20
+ throw new Error(`Missing required string field: ${field}`);
21
+ }
22
+ return value;
23
+ }
24
+ export function optionalStringField(input, field) {
25
+ const value = input[field];
26
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
27
+ }
28
+ export function optionalNumberField(input, field, fallback) {
29
+ const value = input[field];
30
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
31
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@amityco/foundry-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP server for social.plus SDK integration assistance.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/AmityCo/social-plus-foundry.git"
10
+ },
11
+ "homepage": "https://github.com/AmityCo/social-plus-foundry#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/AmityCo/social-plus-foundry/issues"
14
+ },
15
+ "keywords": [
16
+ "social-plus",
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "sdk",
20
+ "ai-coding"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "bin": {
29
+ "foundry-mcp": "dist/server.js",
30
+ "social-plus-foundry": "dist/server.js"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "pack:check": "npm pack --dry-run --cache /private/tmp/social-plus-foundry-npm-cache",
39
+ "publish:check": "npm run validate && npm publish --dry-run --access public --cache /private/tmp/social-plus-foundry-npm-cache",
40
+ "start": "node dist/server.js",
41
+ "test": "npm run build && node test/run-fixtures.mjs",
42
+ "test:cli": "npm run build && node test/run-cli.mjs",
43
+ "test:improvements": "npm run build && node test/run-improvements.mjs",
44
+ "test:mcp": "npm run build && node test/run-mcp-smoke.mjs",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit",
46
+ "validate": "npm run typecheck && npm test && npm run test:mcp && npm run test:cli && npm run test:improvements && npm run pack:check"
47
+ },
48
+ "dependencies": {
49
+ "@modelcontextprotocol/sdk": "^1.12.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^20.11.30",
53
+ "typescript": "^5.4.5"
54
+ }
55
+ }