@reunionstudio/airlock-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.
Files changed (57) hide show
  1. package/.agents/skills/airlock-mcp/SKILL.md +122 -0
  2. package/.agents/skills/airlock-mcp/agents/openai.yaml +4 -0
  3. package/LICENSE +187 -0
  4. package/README.md +126 -0
  5. package/SECURITY.md +31 -0
  6. package/bin/airlock-mcp.mjs +5 -0
  7. package/docs/architecture.md +82 -0
  8. package/docs/install-surface.md +112 -0
  9. package/docs/ooda-loop.md +40 -0
  10. package/docs/spec-workbench-architecture.md +161 -0
  11. package/docs/spec-workspace.md +33 -0
  12. package/docs/workflows.md +229 -0
  13. package/package.json +46 -0
  14. package/patterns/blank/README.md +14 -0
  15. package/patterns/blank/sample.records.json +19 -0
  16. package/patterns/blank/spec.config.json +72 -0
  17. package/patterns/guest-access/individual-isolation.md +27 -0
  18. package/patterns/guest-access/role-isolation.md +26 -0
  19. package/patterns/guest-access/shared-contribution.md +25 -0
  20. package/patterns/manifest.json +16 -0
  21. package/patterns/spec-types/commitment.md +24 -0
  22. package/patterns/spec-types/observation.md +22 -0
  23. package/patterns/spec-types/reconciliation.md +19 -0
  24. package/patterns/spec-types/reference-master-data.md +21 -0
  25. package/patterns/starter-posts/README.md +32 -0
  26. package/patterns/starter-posts/sample.records.json +27 -0
  27. package/patterns/starter-posts/spec.config.json +135 -0
  28. package/schemas/airlock-mcp-workspace.schema.json +14 -0
  29. package/setup.py +41 -0
  30. package/src/airlock_mcp/__init__.py +3 -0
  31. package/src/airlock_mcp/__main__.py +5 -0
  32. package/src/airlock_mcp/art.py +26 -0
  33. package/src/airlock_mcp/bootstrap.py +161 -0
  34. package/src/airlock_mcp/cli.py +450 -0
  35. package/src/airlock_mcp/jsonio.py +56 -0
  36. package/src/airlock_mcp/manage.py +247 -0
  37. package/src/airlock_mcp/models.py +43 -0
  38. package/src/airlock_mcp/patterns.py +49 -0
  39. package/src/airlock_mcp/project.py +39 -0
  40. package/src/airlock_mcp/records.py +43 -0
  41. package/src/airlock_mcp/specs.py +110 -0
  42. package/src/airlock_mcp/sql.py +15 -0
  43. package/src/airlock_mcp/summary.py +115 -0
  44. package/src/airlock_mcp/updater.py +76 -0
  45. package/src/airlock_mcp/validation.py +334 -0
  46. package/src/airlock_mcp/workspace.py +223 -0
  47. package/src/cli.mjs +89 -0
  48. package/src/install.mjs +100 -0
  49. package/src/mcp.mjs +184 -0
  50. package/src/text.mjs +108 -0
  51. package/src/workbench.mjs +368 -0
  52. package/workspaces/_template/brief.md +18 -0
  53. package/workspaces/_template/decisions.md +40 -0
  54. package/workspaces/_template/questions.md +9 -0
  55. package/workspaces/_template/review.md +21 -0
  56. package/workspaces/_template/sample.records.json +19 -0
  57. package/workspaces/_template/spec.config.json +72 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,89 @@
1
+ import { DEFAULT_PACKAGE_SPEC, DEFAULT_SERVER_NAME, helpText } from "./text.mjs";
2
+ import { installCodexServer } from "./install.mjs";
3
+ import { runServer } from "./mcp.mjs";
4
+
5
+ export function parseArgs(argv) {
6
+ const parsed = {
7
+ command: argv[0],
8
+ project: "Home",
9
+ serverName: DEFAULT_SERVER_NAME,
10
+ packageSpec: DEFAULT_PACKAGE_SPEC,
11
+ dryRun: false,
12
+ help: false,
13
+ };
14
+ for (let index = 1; index < argv.length; index += 1) {
15
+ const arg = argv[index];
16
+ if (arg === "--help" || arg === "-h") {
17
+ parsed.help = true;
18
+ } else if (arg === "--project") {
19
+ if (!argv[index + 1] || argv[index + 1].startsWith("--")) {
20
+ throw new Error("--project requires a value");
21
+ }
22
+ parsed.project = argv[index + 1] || parsed.project;
23
+ index += 1;
24
+ } else if (arg === "--name") {
25
+ if (!argv[index + 1] || argv[index + 1].startsWith("--")) {
26
+ throw new Error("--name requires a value");
27
+ }
28
+ parsed.serverName = argv[index + 1] || parsed.serverName;
29
+ index += 1;
30
+ } else if (arg === "--package") {
31
+ if (!argv[index + 1] || argv[index + 1].startsWith("--")) {
32
+ throw new Error("--package requires a value");
33
+ }
34
+ parsed.packageSpec = argv[index + 1] || parsed.packageSpec;
35
+ index += 1;
36
+ } else if (arg === "--dry-run") {
37
+ parsed.dryRun = true;
38
+ } else {
39
+ throw new Error(`unknown argument: ${arg}`);
40
+ }
41
+ }
42
+ return parsed;
43
+ }
44
+
45
+ export async function main(argv = process.argv.slice(2), io = process) {
46
+ let args;
47
+ try {
48
+ args = parseArgs(argv);
49
+ } catch (error) {
50
+ io.stderr.write(`error: ${error.message}\n`);
51
+ return 2;
52
+ }
53
+
54
+ if (!args.command || args.help) {
55
+ io.stdout.write(helpText());
56
+ return 0;
57
+ }
58
+
59
+ if (args.command === "install") {
60
+ try {
61
+ const result = installCodexServer({
62
+ project: args.project,
63
+ serverName: args.serverName,
64
+ packageSpec: args.packageSpec,
65
+ dryRun: args.dryRun,
66
+ beforeRun: (text) => io.stdout.write(text),
67
+ });
68
+ if (result.stdout) {
69
+ io.stdout.write(result.stdout);
70
+ }
71
+ if (result.stderr) {
72
+ io.stderr.write(result.stderr);
73
+ }
74
+ return result.status;
75
+ } catch (error) {
76
+ io.stderr.write(`error: ${error.message}\n`);
77
+ return 2;
78
+ }
79
+ }
80
+
81
+ if (args.command === "server") {
82
+ await runServer();
83
+ return 0;
84
+ }
85
+
86
+ io.stderr.write(`error: unknown command: ${args.command}\n`);
87
+ io.stdout.write(helpText());
88
+ return 2;
89
+ }
@@ -0,0 +1,100 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import { DEFAULT_PACKAGE_SPEC, DEFAULT_SERVER_NAME, nextSteps } from "./text.mjs";
4
+
5
+ const SERVER_NAME_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
6
+ const PACKAGE_SPEC_MAX_LENGTH = 200;
7
+
8
+ export function validateServerName(value) {
9
+ if (!SERVER_NAME_PATTERN.test(value || "")) {
10
+ throw new Error("server name must be 1-64 characters: letters, numbers, dot, underscore, or hyphen");
11
+ }
12
+ return value;
13
+ }
14
+
15
+ export function validatePackageSpec(value) {
16
+ const packageSpec = String(value || "");
17
+ if (
18
+ packageSpec.length < 1 ||
19
+ packageSpec.length > PACKAGE_SPEC_MAX_LENGTH ||
20
+ /[\s\u0000-\u001f\u007f]/.test(packageSpec)
21
+ ) {
22
+ throw new Error("package spec must be 1-200 characters with no whitespace or control characters");
23
+ }
24
+ return packageSpec;
25
+ }
26
+
27
+ export function shellQuote(value) {
28
+ if (/^[a-zA-Z0-9_./:@=-]+$/.test(value)) {
29
+ return value;
30
+ }
31
+ return `'${value.replace(/'/g, "'\\''")}'`;
32
+ }
33
+
34
+ export function codexInstallArgs(serverName = DEFAULT_SERVER_NAME, packageSpec = DEFAULT_PACKAGE_SPEC) {
35
+ return [
36
+ "mcp",
37
+ "add",
38
+ validateServerName(serverName),
39
+ "--",
40
+ "npx",
41
+ "-y",
42
+ validatePackageSpec(packageSpec),
43
+ "server",
44
+ ];
45
+ }
46
+
47
+ export function codexInstallCommand(serverName = DEFAULT_SERVER_NAME, packageSpec = DEFAULT_PACKAGE_SPEC) {
48
+ return ["codex", ...codexInstallArgs(serverName, packageSpec)].map(shellQuote).join(" ");
49
+ }
50
+
51
+ export function installCodexServer({
52
+ project = "Home",
53
+ serverName = DEFAULT_SERVER_NAME,
54
+ packageSpec = DEFAULT_PACKAGE_SPEC,
55
+ dryRun = false,
56
+ beforeRun = () => {},
57
+ spawn = spawnSync,
58
+ } = {}) {
59
+ const command = codexInstallCommand(serverName, packageSpec);
60
+ const intro = `Airlock MCP install
61
+
62
+ Registering Codex MCP server:
63
+ ${command}
64
+ `;
65
+
66
+ if (dryRun) {
67
+ return {
68
+ status: 0,
69
+ stdout: `${intro}
70
+ dry-run: not running registration.
71
+
72
+ ${nextSteps(project)}
73
+ `,
74
+ };
75
+ }
76
+
77
+ beforeRun(`${intro}\n`);
78
+ const result = spawn("codex", codexInstallArgs(serverName, packageSpec), { stdio: "inherit" });
79
+ if (result.error) {
80
+ return {
81
+ status: 1,
82
+ stderr: `error: could not run codex: ${result.error.message}
83
+ Run this command manually after Codex is installed:
84
+ ${command}
85
+ `,
86
+ };
87
+ }
88
+ if (result.status !== 0) {
89
+ return { status: result.status || 1 };
90
+ }
91
+
92
+ return {
93
+ status: 0,
94
+ stdout: `
95
+ installed: Codex MCP server '${serverName}'
96
+
97
+ ${nextSteps(project)}
98
+ `,
99
+ };
100
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,184 @@
1
+ import readline from "node:readline";
2
+
3
+ import { PROTOCOL_VERSION, airlockPrompt, gettingStartedText } from "./text.mjs";
4
+ import { WORKBENCH_TOOLS, callWorkbenchTool } from "./workbench.mjs";
5
+
6
+ const GETTING_STARTED_URI = "airlock://getting-started";
7
+
8
+ const START_TOOL = {
9
+ name: "airlock_start",
10
+ description: "Return Airlock MCP setup guidance for building and using Airlock specs.",
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {
14
+ project: {
15
+ type: "string",
16
+ description: "Project or organization name, for example Home.",
17
+ maxLength: 80,
18
+ },
19
+ },
20
+ additionalProperties: false,
21
+ },
22
+ };
23
+
24
+ export const AIRLOCK_TOOLS = [START_TOOL, ...WORKBENCH_TOOLS];
25
+
26
+ export function makeResponse(id, result) {
27
+ return { jsonrpc: "2.0", id, result };
28
+ }
29
+
30
+ export function makeError(id, code, message) {
31
+ return { jsonrpc: "2.0", id, error: { code, message } };
32
+ }
33
+
34
+ export function handleMcpRequest(message) {
35
+ const { id, method, params } = message;
36
+
37
+ if (method === "initialize") {
38
+ return makeResponse(id, {
39
+ protocolVersion: params?.protocolVersion || PROTOCOL_VERSION,
40
+ capabilities: {
41
+ prompts: {},
42
+ resources: {},
43
+ tools: {},
44
+ },
45
+ serverInfo: {
46
+ name: "airlock",
47
+ version: "0.1.0",
48
+ },
49
+ instructions:
50
+ "Airlock MCP helps agents build and use Airlock specs. Use airlock_start for orientation or the airlock_* tools to bootstrap, draft, check, summarize, export, and render specs.",
51
+ });
52
+ }
53
+
54
+ if (method === "notifications/initialized") {
55
+ return undefined;
56
+ }
57
+
58
+ if (method === "tools/list") {
59
+ return makeResponse(id, { tools: AIRLOCK_TOOLS });
60
+ }
61
+
62
+ if (method === "tools/call") {
63
+ const name = params?.name || "";
64
+ if (name === "airlock_start") {
65
+ const project = params?.arguments?.project || "Home";
66
+ return makeResponse(id, {
67
+ content: [{ type: "text", text: gettingStartedText(project) }],
68
+ });
69
+ }
70
+ try {
71
+ const result = callWorkbenchTool(name, params?.arguments || {});
72
+ if (result) {
73
+ return makeResponse(id, result);
74
+ }
75
+ return makeError(id, -32602, `unknown tool: ${name}`);
76
+ } catch (error) {
77
+ return makeError(id, -32602, error.message);
78
+ }
79
+ }
80
+
81
+ if (method === "prompts/list") {
82
+ return makeResponse(id, {
83
+ prompts: [
84
+ {
85
+ name: "airlock-start",
86
+ title: "Start Airlock",
87
+ description: "Bootstrap a blank specs repo and choose the first Airlock path.",
88
+ arguments: [
89
+ {
90
+ name: "project",
91
+ description: "Project or organization name, for example Home.",
92
+ required: false,
93
+ },
94
+ ],
95
+ },
96
+ ],
97
+ });
98
+ }
99
+
100
+ if (method === "prompts/get") {
101
+ if (params?.name !== "airlock-start") {
102
+ return makeError(id, -32602, `unknown prompt: ${params?.name || ""}`);
103
+ }
104
+ const project = params?.arguments?.project || "Home";
105
+ return makeResponse(id, {
106
+ description: "Start building and using Airlock specs in a blank specs repo.",
107
+ messages: [
108
+ {
109
+ role: "user",
110
+ content: { type: "text", text: airlockPrompt(project) },
111
+ },
112
+ ],
113
+ });
114
+ }
115
+
116
+ if (method === "resources/list") {
117
+ return makeResponse(id, {
118
+ resources: [
119
+ {
120
+ uri: GETTING_STARTED_URI,
121
+ name: "Airlock getting started",
122
+ description: "How to start building and using Airlock specs with Codex.",
123
+ mimeType: "text/markdown",
124
+ },
125
+ ],
126
+ });
127
+ }
128
+
129
+ if (method === "resources/read") {
130
+ if (params?.uri !== GETTING_STARTED_URI) {
131
+ return makeError(id, -32602, `unknown resource: ${params?.uri || ""}`);
132
+ }
133
+ return makeResponse(id, {
134
+ contents: [
135
+ {
136
+ uri: params.uri,
137
+ mimeType: "text/markdown",
138
+ text: gettingStartedText("Home"),
139
+ },
140
+ ],
141
+ });
142
+ }
143
+
144
+ return makeError(id, -32601, `method not found: ${method}`);
145
+ }
146
+
147
+ export function encodeMessage(message) {
148
+ return `${JSON.stringify(message)}\n`;
149
+ }
150
+
151
+ export async function runServer({ input = process.stdin, output = process.stdout } = {}) {
152
+ const rl = readline.createInterface({
153
+ input,
154
+ crlfDelay: Infinity,
155
+ });
156
+
157
+ for await (const line of rl) {
158
+ if (!line.trim()) {
159
+ continue;
160
+ }
161
+
162
+ let message;
163
+ try {
164
+ message = JSON.parse(line);
165
+ } catch (_error) {
166
+ output.write(encodeMessage(makeError(null, -32700, "parse error")));
167
+ continue;
168
+ }
169
+
170
+ if (message.id === undefined) {
171
+ handleMcpRequest(message);
172
+ continue;
173
+ }
174
+
175
+ try {
176
+ const response = handleMcpRequest(message);
177
+ if (response) {
178
+ output.write(encodeMessage(response));
179
+ }
180
+ } catch (error) {
181
+ output.write(encodeMessage(makeError(message.id, -32603, error.message)));
182
+ }
183
+ }
184
+ }
package/src/text.mjs ADDED
@@ -0,0 +1,108 @@
1
+ export const PACKAGE_NAME = "@reunionstudio/airlock-mcp";
2
+ export const DEFAULT_PACKAGE_SPEC = PACKAGE_NAME;
3
+ export const DEFAULT_SERVER_NAME = "airlock";
4
+ export const PROTOCOL_VERSION = "2025-06-18";
5
+
6
+ export function slug(value) {
7
+ const cleaned = String(value || "project")
8
+ .trim()
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, "-")
11
+ .replace(/^-+|-+$/g, "");
12
+ return cleaned || "project";
13
+ }
14
+
15
+ export function specsRepoName(project) {
16
+ const value = slug(project);
17
+ return value.endsWith("-specs") ? value : `${value}-specs`;
18
+ }
19
+
20
+ export function airlockPrompt(project) {
21
+ const repoName = specsRepoName(project);
22
+ return `I want to use Airlock MCP to start working with Airlock specs for ${repoName}.
23
+
24
+ Set up this project as an Airlock specs repo. Use Airlock MCP
25
+ spec-building when we need to draft or revise specs. Welcome me,
26
+ help me think through real Airlock use cases, and ask only for the missing
27
+ decisions. Do not create the first workspace until I choose a path.`;
28
+ }
29
+
30
+ export function nextSteps(project) {
31
+ const repoName = specsRepoName(project);
32
+ return `Next:
33
+ 1. Open Codex.
34
+ 2. Create a new blank project named ${repoName}.
35
+ 3. Ask Codex:
36
+
37
+ ${airlockPrompt(project)
38
+ .split("\n")
39
+ .map((line) => ` ${line}`)
40
+ .join("\n")}
41
+
42
+ Airlock MCP will offer:
43
+ - spec-building with the bundled workbench
44
+ - spec use and improvement loops with Airlock Star
45
+ - guidance for pulling and pushing governed data through specs
46
+ - OODA brainstorming for possible specs
47
+ - a blank workspace for a known process
48
+ - a posts feedback loop for shared human/agent feedback`;
49
+ }
50
+
51
+ export function gettingStartedText(project) {
52
+ return `# Airlock MCP
53
+
54
+ Airlock MCP is the single installed interface for AI agents working with
55
+ Airlock. It helps a person and their agent build specs, pull and push governed data
56
+ through specs, and improve Airlock workflows from real use cases.
57
+
58
+ The Airlock MCP spec-building workbench drafts, checks, revises, imports,
59
+ clones, and prepares specs for installed Airlock validation.
60
+
61
+ Airlock Star is the use-and-improve capability inside Airlock MCP. Use it when
62
+ someone wants to pull or push governed data through specs, exercise real
63
+ Airlock workflows, inspect outputs, or turn field experience into spec
64
+ improvements.
65
+
66
+ Start in a blank project specs repo such as ${specsRepoName(project)}. Do not
67
+ work inside the Airlock MCP implementation repo unless you are changing the
68
+ tools themselves.
69
+
70
+ Use this prompt in the blank specs repo:
71
+
72
+ ${airlockPrompt(project)}
73
+
74
+ After bootstrap, choose the next useful path:
75
+
76
+ 1. Brainstorm possible specs using the OODA loop.
77
+ 2. Start from a known process and create a blank workspace.
78
+ 3. Create a posts feedback loop for humans and agents to submit requests,
79
+ observations, and responses.
80
+ 4. Use Airlock Star with an installed Airlock app to validate specs, load data,
81
+ read outputs, plan push/pull workflows, and capture improvements.
82
+
83
+ Create posts only when the user chooses the feedback-loop path.`;
84
+ }
85
+
86
+ export function helpText() {
87
+ return `airlock-mcp
88
+
89
+ Usage:
90
+ npx @reunionstudio/airlock-mcp install [--project <name>] [--name <server-name>] [--package <spec>] [--dry-run]
91
+ npx @reunionstudio/airlock-mcp server
92
+
93
+ Commands:
94
+ install Register the Airlock MCP server with Codex.
95
+ server Run the Airlock MCP stdio server.
96
+
97
+ Options:
98
+ --project <name> Show next steps using <name>-specs.
99
+ --name <server-name> Codex MCP server name. Defaults to airlock.
100
+ --package <spec> npm or GitHub package spec for the registered server.
101
+ --dry-run Print the Codex registration command without running it.
102
+ --help Show this help.
103
+
104
+ Airlock MCP is the single installed interface for agents working with Airlock.
105
+ Spec building is bundled inside that experience.
106
+ Airlock Star is the use-and-improve capability inside that experience.
107
+ `;
108
+ }