@flow-engineer/flow 0.2.1 → 0.2.3

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 (70) hide show
  1. package/README.md +167 -10
  2. package/coding-agents/auth-skill.md +137 -0
  3. package/coding-agents/context-skill.md +94 -0
  4. package/coding-agents/skill.md +219 -17
  5. package/coding-agents/test-skill.md +3 -1
  6. package/coding-agents/tools-skill.md +78 -1
  7. package/dist/cli/src/auth.d.ts +11 -0
  8. package/dist/cli/src/auth.js +147 -0
  9. package/dist/cli/src/commands.d.ts +119 -0
  10. package/dist/cli/src/commands.js +212 -0
  11. package/dist/cli/src/index.d.ts +2 -0
  12. package/dist/cli/src/index.js +5 -0
  13. package/dist/cli/src/run.d.ts +28 -0
  14. package/dist/cli/src/run.js +523 -0
  15. package/dist/cli/src/terminal.d.ts +3 -0
  16. package/dist/cli/src/terminal.js +135 -0
  17. package/dist/flow-messaging/src/client.d.ts +2 -2
  18. package/dist/flow-messaging/src/client.js +3 -3
  19. package/dist/flow-messaging/src/index.js +17 -9
  20. package/dist/mcp/src/client.d.ts +54 -0
  21. package/dist/mcp/src/client.js +175 -0
  22. package/dist/mcp/src/describe.d.ts +41 -0
  23. package/dist/mcp/src/describe.js +110 -0
  24. package/dist/mcp/src/index.d.ts +4 -0
  25. package/dist/mcp/src/index.js +7 -0
  26. package/dist/mcp/src/server.d.ts +70 -0
  27. package/dist/mcp/src/server.js +252 -0
  28. package/dist/mcp/src/terminal.d.ts +24 -0
  29. package/dist/mcp/src/terminal.js +54 -0
  30. package/dist/simulators/src/index.d.ts +1 -1
  31. package/dist/simulators/src/page.html +267 -49
  32. package/dist/simulators/src/server.d.ts +27 -0
  33. package/dist/simulators/src/server.js +264 -40
  34. package/dist/src/agent.d.ts +69 -3
  35. package/dist/src/agent.js +162 -8
  36. package/dist/src/channels.d.ts +6 -4
  37. package/dist/src/channels.js +4 -3
  38. package/dist/src/conversation.d.ts +17 -1
  39. package/dist/src/conversation.js +170 -16
  40. package/dist/src/events.d.ts +1 -1
  41. package/dist/src/index.d.ts +2 -2
  42. package/dist/src/index.js +1 -1
  43. package/dist/src/model.d.ts +27 -0
  44. package/dist/src/model.js +85 -13
  45. package/dist/src/skills.d.ts +4 -0
  46. package/dist/src/skills.js +23 -0
  47. package/dist/src/store.d.ts +2 -0
  48. package/dist/src/store.js +18 -0
  49. package/dist/src/tools.d.ts +69 -0
  50. package/dist/src/tools.js +17 -1
  51. package/dist/src/traces.js +32 -4
  52. package/dist/src/types.d.ts +5 -0
  53. package/dist/testing/src/account.d.ts +1 -1
  54. package/dist/testing/src/account.js +368 -57
  55. package/dist/testing/src/chat.js +24 -16
  56. package/dist/testing/src/check.d.ts +4 -0
  57. package/dist/testing/src/check.js +90 -0
  58. package/dist/testing/src/cli.js +77 -19
  59. package/dist/testing/src/flow-login.d.ts +33 -0
  60. package/dist/testing/src/flow-login.js +30 -5
  61. package/dist/testing/src/new.d.ts +14 -5
  62. package/dist/testing/src/new.js +54 -20
  63. package/dist/testing/src/plugin.d.ts +4 -0
  64. package/dist/testing/src/plugin.js +116 -0
  65. package/docs/agents.md +87 -12
  66. package/examples/cafe/src/cli.ts +24 -0
  67. package/examples/cafe/src/mcp.ts +22 -0
  68. package/examples/cafe/src/tools.ts +13 -0
  69. package/flow-messaging/README.md +3 -1
  70. package/package.json +28 -1
@@ -66,9 +66,9 @@ export class Client {
66
66
  throw await errorOf(response);
67
67
  return new Uint8Array(await response.arrayBuffer());
68
68
  }
69
- /** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
70
- post(path, body, tries = TRIES) {
71
- return this.request("POST", path, body, randomUUID(), tries);
69
+ /** A POST with its own Idempotency-Key (the caller's, else a new one), kept across the retries of this one call; `tries: 1` for courtesies. */
70
+ post(path, body, tries = TRIES, idempotencyKey = randomUUID()) {
71
+ return this.request("POST", path, body, idempotencyKey, tries);
72
72
  }
73
73
  async request(method, path, body, idempotencyKey, tries = TRIES) {
74
74
  if (!this.apiKey)
@@ -97,16 +97,16 @@ export function flowMessaging(options = {}) {
97
97
  return { phone: to.startsWith("+") ? to : `+${to}` };
98
98
  }
99
99
  /** One message: into the person's conversation, or a new one from the channel's sender. */
100
- async function send(route, to, content, replyTo, about = replyTo) {
100
+ async function send(route, to, content, replyTo, about = replyTo, idempotencyKey) {
101
101
  try {
102
102
  const conversation = await conversationFor(route.name, to, about);
103
103
  let sent;
104
104
  if (conversation)
105
- sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
105
+ sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) }, undefined, idempotencyKey);
106
106
  else {
107
107
  if (!route.sender)
108
108
  throw new Error(`no conversation with them on ${route.name} yet, and no sender to start one (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
109
- sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content });
109
+ sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content }, undefined, idempotencyKey);
110
110
  }
111
111
  flowConversation.set(key(route.name, to), sent.conversation);
112
112
  }
@@ -114,7 +114,11 @@ export function flowMessaging(options = {}) {
114
114
  throw error instanceof FlowMessagingError ? new Error(reason(error)) : error;
115
115
  }
116
116
  }
117
- /** Typing and read receipts are a courtesy: Flow's spec says to ignore their failures. */
117
+ /**
118
+ * Typing and read receipts are a courtesy: Flow's spec says to ignore their failures. Outside the
119
+ * window (iMessage and WhatsApp until the person writes) they are expected to be refused, so that
120
+ * is not logged; any other failure is, once.
121
+ */
118
122
  async function courtesy(route, to, action, body, messageId) {
119
123
  try {
120
124
  const conversation = await conversationFor(route.name, to, messageId);
@@ -122,6 +126,8 @@ export function flowMessaging(options = {}) {
122
126
  await client.post(`/v1/conversations/${idSegment("conv", conversation)}/${action}`, body, 1);
123
127
  }
124
128
  catch (error) {
129
+ if (error instanceof FlowMessagingError && error.type === "outside_window")
130
+ return;
125
131
  console.warn(`@flow-engineer/flow/flow-messaging: ${action} on ${route.name} failed (${error instanceof FlowMessagingError ? error.type : "error"})`);
126
132
  }
127
133
  }
@@ -170,19 +176,20 @@ export function flowMessaging(options = {}) {
170
176
  return {
171
177
  sendMessage: async (to, message) => {
172
178
  const [first, ...rest] = contents(route.kind, message);
173
- await send(route, to, first, message.replyTo);
174
- for (const next of rest)
175
- await send(route, to, next);
179
+ const key = message.idempotencyKey;
180
+ await send(route, to, first, message.replyTo, message.replyTo, key);
181
+ for (const [i, next] of rest.entries())
182
+ await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}`);
176
183
  },
177
184
  react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
178
185
  typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
179
186
  markRead: (to, messageIds) => courtesy(route, to, "read", { up_to: messageIds.at(-1) }, messageIds.at(-1)),
180
- sendTemplate: async (to, wanted) => {
187
+ sendTemplate: async (to, wanted, idempotencyKey) => {
181
188
  if (!route.sender)
182
189
  throw new Error(`a template needs the channel's sender (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
183
190
  const found = await template(route.sender, wanted);
184
191
  const params = templateParams(wanted);
185
- await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) });
192
+ await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) }, undefined, undefined, idempotencyKey);
186
193
  },
187
194
  };
188
195
  }
@@ -226,6 +233,7 @@ export function flowMessaging(options = {}) {
226
233
  ...(outbound.buttons && info.buttons ? { buttons: outbound.buttons } : {}),
227
234
  ...(outbound.link && info.linkButton ? { link: outbound.link } : {}),
228
235
  ...(outbound.image && info.images ? { image: outbound.image } : {}),
236
+ ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
229
237
  });
230
238
  case "reaction":
231
239
  return p.react(to, outbound.messageId, outbound.emoji);
@@ -0,0 +1,54 @@
1
+ import { type Tool } from "../../src/tools.ts";
2
+ export interface McpToolsOptions {
3
+ /** The server's name in mcp.lock.json. */
4
+ name: string;
5
+ /** Its Streamable HTTP endpoint. */
6
+ url: string;
7
+ /** Sent with every request, such as `authorization`. Keep keys in the environment, never in the lock. */
8
+ headers?: Record<string, string>;
9
+ /** Only these of its tools (default: every tool in the lock). */
10
+ only?: string[];
11
+ /** Put before each tool's name, to keep two servers' tools apart (default: none). */
12
+ prefix?: string;
13
+ /** Take the server's tools as they are now, with no lock (default false). */
14
+ live?: boolean;
15
+ /** Default: mcp.lock.json in the current folder (the agent's, for flow commands). */
16
+ lockFile?: string;
17
+ }
18
+ /** A tool as the server described it, as tools/list sends it. */
19
+ export interface McpDescriptor {
20
+ name: string;
21
+ title?: string;
22
+ description?: string;
23
+ inputSchema: Record<string, unknown>;
24
+ outputSchema?: Record<string, unknown>;
25
+ annotations?: {
26
+ title?: string;
27
+ readOnlyHint?: boolean;
28
+ destructiveHint?: boolean;
29
+ idempotentHint?: boolean;
30
+ openWorldHint?: boolean;
31
+ };
32
+ }
33
+ export interface McpLock {
34
+ servers: Record<string, {
35
+ url: string;
36
+ tools: Record<string, {
37
+ fingerprint: string;
38
+ descriptor: McpDescriptor;
39
+ }>;
40
+ }>;
41
+ }
42
+ export declare const RECHECK_MS: number;
43
+ /** Set by `flow mcp update`: mcpTools writes the server's current tools into the lock. */
44
+ export declare const UPDATE_ENV = "CHINWAG_MCP_UPDATE";
45
+ export declare function mcpTools(options: McpToolsOptions): Promise<Tool[]>;
46
+ /** The fingerprint of what a model and a host see of a tool: any change to it is a change. */
47
+ export declare function fingerprint(d: McpDescriptor): string;
48
+ export declare function readLock(lockFile: string): McpLock;
49
+ /**
50
+ * A model's placeholder for an argument it did not mean (null, "" or []) is left out when the schema
51
+ * does not require it, also inside objects: servers' validation refused them (Flow, 6 Oct 2026).
52
+ * false and 0 are real values and stay.
53
+ */
54
+ export declare function withoutPlaceholders(value: Record<string, unknown>, schema: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,175 @@
1
+ // An existing MCP server's tools as Chinwag tools, so a chat agent uses them like its own.
2
+ //
3
+ // Pinned by default: the tools come from mcp.lock.json in the agent's folder (each tool's
4
+ // descriptor and fingerprint, written by `flow mcp update`), never from what the server says
5
+ // today. A tool the server has changed or removed since is refused with a clear error until the
6
+ // lock is updated and committed: what was tested is what runs. Building the tools reads only the
7
+ // lock; the server is first reached on a call, and its list is read again before a call once the
8
+ // last look is older than RECHECK_MS. `live: true` takes the server's tools as they are instead.
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
13
+ import { z } from "zod";
14
+ import { INPUT_SCHEMA } from "../../src/tools.js";
15
+ export const RECHECK_MS = 5 * 60_000;
16
+ /** Set by `flow mcp update`: mcpTools writes the server's current tools into the lock. */
17
+ export const UPDATE_ENV = "CHINWAG_MCP_UPDATE";
18
+ const IDEMPOTENCY_META = "chinwag/idempotency-key";
19
+ export async function mcpTools(options) {
20
+ const lockFile = options.lockFile ?? join(process.cwd(), "mcp.lock.json");
21
+ const connection = connect(options);
22
+ if (options.live || process.env[UPDATE_ENV] === "1") {
23
+ const current = await connection.list();
24
+ if (!options.live)
25
+ writeLock(lockFile, options, current);
26
+ return picked(options, [...current.values()]).map((d) => wrap(options, d, connection, null));
27
+ }
28
+ const entry = readLock(lockFile).servers[options.name];
29
+ if (!entry)
30
+ throw new Error(`@flow-engineer/flow/mcp: ${options.name} is not in ${lockFile}. Run flow mcp update from the agent's folder, then commit the file.`);
31
+ return picked(options, Object.values(entry.tools).map((t) => t.descriptor)).map((d) => wrap(options, d, connection, entry.tools[d.name].fingerprint));
32
+ }
33
+ function picked(options, descriptors) {
34
+ return options.only ? descriptors.filter((d) => options.only.includes(d.name)) : descriptors;
35
+ }
36
+ /** The fingerprint of what a model and a host see of a tool: any change to it is a change. */
37
+ export function fingerprint(d) {
38
+ const seen = { name: d.name, title: d.title, description: d.description, inputSchema: d.inputSchema, outputSchema: d.outputSchema, annotations: d.annotations };
39
+ return createHash("sha256").update(canonical(seen)).digest("hex");
40
+ }
41
+ function canonical(value) {
42
+ if (Array.isArray(value))
43
+ return `[${value.map(canonical).join(",")}]`;
44
+ if (value && typeof value === "object") {
45
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => (a < b ? -1 : 1));
46
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
47
+ }
48
+ return JSON.stringify(value);
49
+ }
50
+ export function readLock(lockFile) {
51
+ if (!existsSync(lockFile))
52
+ return { servers: {} };
53
+ return JSON.parse(readFileSync(lockFile, "utf8"));
54
+ }
55
+ function writeLock(lockFile, options, current) {
56
+ const lock = readLock(lockFile);
57
+ const before = lock.servers[options.name]?.tools ?? {};
58
+ const tools = {};
59
+ for (const d of picked(options, [...current.values()]).sort((a, b) => (a.name < b.name ? -1 : 1)))
60
+ tools[d.name] = { fingerprint: fingerprint(d), descriptor: d };
61
+ for (const name of Object.keys(tools)) {
62
+ if (!before[name])
63
+ console.error(` + ${options.name}.${name}`);
64
+ else if (before[name].fingerprint !== tools[name].fingerprint)
65
+ console.error(` ~ ${options.name}.${name} changed`);
66
+ }
67
+ for (const name of Object.keys(before))
68
+ if (!tools[name])
69
+ console.error(` - ${options.name}.${name} removed`);
70
+ lock.servers[options.name] = { url: options.url, tools };
71
+ const sorted = { servers: Object.fromEntries(Object.entries(lock.servers).sort(([a], [b]) => (a < b ? -1 : 1))) };
72
+ writeFileSync(lockFile, JSON.stringify(sorted, null, 2) + "\n");
73
+ }
74
+ function connect(options) {
75
+ let client;
76
+ let last;
77
+ function open() {
78
+ client ??= (async () => {
79
+ const c = new Client({ name: "flow", version: "1" });
80
+ await c.connect(new StreamableHTTPClientTransport(new URL(options.url), { requestInit: { headers: options.headers ?? {} } }));
81
+ return c;
82
+ })().catch((error) => {
83
+ client = undefined;
84
+ throw error;
85
+ });
86
+ return client;
87
+ }
88
+ async function list() {
89
+ const c = await open();
90
+ const tools = new Map();
91
+ let cursor;
92
+ do {
93
+ const page = await c.listTools(cursor ? { cursor } : {});
94
+ for (const t of page.tools)
95
+ tools.set(t.name, stripped(t));
96
+ cursor = page.nextCursor;
97
+ } while (cursor);
98
+ last = { at: Date.now(), tools };
99
+ return tools;
100
+ }
101
+ return {
102
+ list,
103
+ recent: async () => (last && Date.now() - last.at < RECHECK_MS ? last.tools : list()),
104
+ async call(name, args, meta, signal) {
105
+ const c = await open();
106
+ return (await c.callTool({ name, arguments: args, _meta: meta }, { signal }));
107
+ },
108
+ };
109
+ }
110
+ /** Only what the lock keeps of a descriptor. */
111
+ function stripped(t) {
112
+ return { name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, outputSchema: t.outputSchema, annotations: t.annotations };
113
+ }
114
+ function wrap(options, d, connection, locked) {
115
+ const a = d.annotations ?? {};
116
+ const hints = {
117
+ // A tool that does not say it only reads is taken as a write.
118
+ readOnly: a.readOnlyHint === true,
119
+ destructive: a.destructiveHint ?? true,
120
+ idempotent: a.idempotentHint ?? false,
121
+ openWorld: a.openWorldHint ?? true,
122
+ };
123
+ const t = {
124
+ name: `${options.prefix ?? ""}${d.name}`,
125
+ title: d.title ?? a.title,
126
+ description: d.description ?? "",
127
+ input: validator(d.inputSchema),
128
+ hints,
129
+ [INPUT_SCHEMA]: d.inputSchema,
130
+ async run(input, context) {
131
+ if (locked) {
132
+ const now = (await connection.recent()).get(d.name);
133
+ if (!now)
134
+ throw new Error(`${options.name} no longer has ${d.name}. The agent's mcp.lock.json must be updated (flow mcp update) before it can be used.`);
135
+ if (fingerprint(now) !== locked)
136
+ throw new Error(`${options.name} changed ${d.name} since it was tested. The agent's mcp.lock.json must be updated (flow mcp update) before it can be used.`);
137
+ }
138
+ const result = await connection.call(d.name, withoutPlaceholders(input, d.inputSchema), { [IDEMPOTENCY_META]: context.idempotencyKey }, context.signal);
139
+ const text = result.content
140
+ .map((block) => (block.type === "text" ? block.text : `[${block.type} not shown]`))
141
+ .join("\n");
142
+ if (result.isError)
143
+ throw new Error(text || `${d.name} failed.`);
144
+ return result.structuredContent ?? text;
145
+ },
146
+ };
147
+ return t;
148
+ }
149
+ /** Arguments are checked by the zod made from the server's schema; the server checks them again. */
150
+ function validator(schema) {
151
+ try {
152
+ return z.fromJSONSchema(schema);
153
+ }
154
+ catch {
155
+ return z.looseObject({});
156
+ }
157
+ }
158
+ /**
159
+ * A model's placeholder for an argument it did not mean (null, "" or []) is left out when the schema
160
+ * does not require it, also inside objects: servers' validation refused them (Flow, 6 Oct 2026).
161
+ * false and 0 are real values and stay.
162
+ */
163
+ export function withoutPlaceholders(value, schema) {
164
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
165
+ const properties = (schema.properties ?? {});
166
+ const out = {};
167
+ for (const [key, v] of Object.entries(value)) {
168
+ const empty = v === null || v === "" || (Array.isArray(v) && v.length === 0);
169
+ if (empty && !required.has(key))
170
+ continue;
171
+ const nested = properties[key];
172
+ out[key] = v && typeof v === "object" && !Array.isArray(v) && nested?.properties ? withoutPlaceholders(v, nested) : v;
173
+ }
174
+ return out;
175
+ }
@@ -0,0 +1,41 @@
1
+ import type { SignIn, Tool } from "../../src/tools.ts";
2
+ /** An MCP tool as `tools/list` sends it. */
3
+ export interface McpToolDescriptor {
4
+ name: string;
5
+ title: string;
6
+ description: string;
7
+ inputSchema: {
8
+ type: "object";
9
+ [key: string]: unknown;
10
+ };
11
+ outputSchema?: {
12
+ [key: string]: unknown;
13
+ };
14
+ annotations: {
15
+ title: string;
16
+ readOnlyHint: boolean;
17
+ destructiveHint: boolean;
18
+ idempotentHint: boolean;
19
+ openWorldHint: boolean;
20
+ };
21
+ _meta: Record<string, unknown>;
22
+ }
23
+ /** The scopes an MCP server asks for: reads need `read`, writes `write` (both when given). */
24
+ export interface Scopes {
25
+ read?: string;
26
+ write?: string;
27
+ }
28
+ /** Tools that exist only in a chat: a description naming one points a host at a tool it does not have. */
29
+ export declare const CHAT_ONLY: RegExp;
30
+ export declare function signInOf(tool: Tool): SignIn;
31
+ export declare function isReadOnly(tool: Tool): boolean;
32
+ /** The scopes a call needs, given the server's scope names. */
33
+ export declare function scopesFor(tool: Tool, scopes: Scopes | undefined): string[];
34
+ /** "place_order" → "Place order". */
35
+ export declare function titleOf(tool: Tool): string;
36
+ /** The MCP descriptor for a tool. Throws when its input or output has no exact JSON Schema. */
37
+ export declare function describe(tool: Tool, scopes?: Scopes): McpToolDescriptor;
38
+ /** Why a tool cannot be served over MCP exactly as declared, one sentence each (empty when it can). */
39
+ export declare function toolProblems(tool: Tool): string[];
40
+ /** Problems across a server's tools: each tool's, and names used twice. */
41
+ export declare function toolsProblems(tools: Tool[]): string[];
@@ -0,0 +1,110 @@
1
+ // A Chinwag tool as an MCP tool (spec 2026-07-28), and the rules that make that translation exact.
2
+ // Every field of the descriptor comes from the tool or a stated default; nothing is guessed, and a
3
+ // tool that cannot be described exactly is a problem `flow check` reports, not a schema we bend.
4
+ import { z } from "zod";
5
+ const NAME = /^[A-Za-z0-9_.-]{1,128}$/;
6
+ const STATUS_MAX = 64;
7
+ /** Tools that exist only in a chat: a description naming one points a host at a tool it does not have. */
8
+ export const CHAT_ONLY = /\b(send_[a-z0-9_]+|share_updates|load_skill)\b/;
9
+ export function signInOf(tool) {
10
+ return tool.signIn ?? "required";
11
+ }
12
+ export function isReadOnly(tool) {
13
+ return tool.hints?.readOnly ?? false;
14
+ }
15
+ /** The scopes a call needs, given the server's scope names. */
16
+ export function scopesFor(tool, scopes) {
17
+ const name = isReadOnly(tool) ? scopes?.read : scopes?.write;
18
+ return name ? [name] : [];
19
+ }
20
+ /** "place_order" → "Place order". */
21
+ export function titleOf(tool) {
22
+ if (tool.title)
23
+ return tool.title;
24
+ const words = tool.name.replace(/[_.-]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").trim().toLowerCase();
25
+ return words.charAt(0).toUpperCase() + words.slice(1);
26
+ }
27
+ function jsonSchema(schema) {
28
+ const { $schema: _ignored, ...rest } = z.toJSONSchema(schema, { target: "draft-2020-12", unrepresentable: "throw", io: "input" });
29
+ return rest;
30
+ }
31
+ /** The MCP descriptor for a tool. Throws when its input or output has no exact JSON Schema. */
32
+ export function describe(tool, scopes) {
33
+ const readOnly = isReadOnly(tool);
34
+ const title = titleOf(tool);
35
+ const inputSchema = jsonSchema(tool.input);
36
+ const signIn = signInOf(tool);
37
+ const oauth = { type: "oauth2", scopes: scopesFor(tool, scopes) };
38
+ const _meta = {
39
+ // ChatGPT reads sign-in per tool from here; the spec keeps it at the HTTP layer only.
40
+ securitySchemes: signIn === "none" ? [{ type: "noauth" }] : signIn === "optional" ? [{ type: "noauth" }, oauth] : [oauth],
41
+ };
42
+ if (tool.status?.running)
43
+ _meta["openai/toolInvocation/invoking"] = tool.status.running;
44
+ if (tool.status?.done)
45
+ _meta["openai/toolInvocation/invoked"] = tool.status.done;
46
+ return {
47
+ name: tool.name,
48
+ title,
49
+ description: tool.description,
50
+ inputSchema,
51
+ ...(tool.output ? { outputSchema: jsonSchema(tool.output) } : {}),
52
+ annotations: {
53
+ title,
54
+ readOnlyHint: readOnly,
55
+ destructiveHint: readOnly ? false : (tool.hints?.destructive ?? true),
56
+ idempotentHint: tool.hints?.idempotent ?? false,
57
+ openWorldHint: tool.hints?.openWorld ?? true,
58
+ },
59
+ _meta,
60
+ };
61
+ }
62
+ /** Why a tool cannot be served over MCP exactly as declared, one sentence each (empty when it can). */
63
+ export function toolProblems(tool) {
64
+ const problems = [];
65
+ const at = `tool ${tool.name}`;
66
+ if (!NAME.test(tool.name))
67
+ problems.push(`${at}: the name must be 1 to 128 of A-Z a-z 0-9 _ - . (MCP's tool names)`);
68
+ if (!tool.description.trim())
69
+ problems.push(`${at}: the description is empty; hosts show it to their model`);
70
+ const chatOnly = CHAT_ONLY.exec(tool.description);
71
+ if (chatOnly)
72
+ problems.push(`${at}: the description names ${chatOnly[1]}, a tool only chats have`);
73
+ if (!(tool.input instanceof z.ZodObject))
74
+ problems.push(`${at}: input must be a z.object (MCP needs an object at the root)`);
75
+ else {
76
+ try {
77
+ jsonSchema(tool.input);
78
+ }
79
+ catch (error) {
80
+ problems.push(`${at}: input has no exact JSON Schema (${error.message})`);
81
+ }
82
+ }
83
+ if (tool.output) {
84
+ try {
85
+ jsonSchema(tool.output);
86
+ }
87
+ catch (error) {
88
+ problems.push(`${at}: output has no exact JSON Schema (${error.message})`);
89
+ }
90
+ }
91
+ if (tool.hints?.readOnly && tool.hints.destructive)
92
+ problems.push(`${at}: hints say readOnly and destructive; a tool that changes nothing destroys nothing`);
93
+ for (const key of ["running", "done"]) {
94
+ const line = tool.status?.[key];
95
+ if (line !== undefined && (line.length === 0 || line.length > STATUS_MAX))
96
+ problems.push(`${at}: status.${key} must be 1 to ${STATUS_MAX} characters`);
97
+ }
98
+ return problems;
99
+ }
100
+ /** Problems across a server's tools: each tool's, and names used twice. */
101
+ export function toolsProblems(tools) {
102
+ const problems = tools.flatMap(toolProblems);
103
+ const seen = new Set();
104
+ for (const t of tools) {
105
+ if (seen.has(t.name))
106
+ problems.push(`tool ${t.name}: two tools have this name`);
107
+ seen.add(t.name);
108
+ }
109
+ return problems;
110
+ }
@@ -0,0 +1,4 @@
1
+ export { mcpServer, type McpAuth, type McpIdentity, type McpServerHandle, type McpServerOptions } from "./server.ts";
2
+ export { mcpTools, fingerprint, type McpDescriptor, type McpLock, type McpToolsOptions } from "./client.ts";
3
+ export { describe, titleOf, toolProblems, toolsProblems, type McpToolDescriptor, type Scopes } from "./describe.ts";
4
+ export { terminalServer, type TerminalServerOptions } from "./terminal.ts";
@@ -0,0 +1,7 @@
1
+ // @flow-engineer/flow/mcp: an agent's tools and skills as an MCP server, and other MCP servers' tools as an
2
+ // agent's. Needs @modelcontextprotocol/server (serving) or @modelcontextprotocol/client (using),
3
+ // optional peers of Flow Framework installed only by agents that turn MCP on.
4
+ export { mcpServer } from "./server.js";
5
+ export { mcpTools, fingerprint } from "./client.js";
6
+ export { describe, titleOf, toolProblems, toolsProblems } from "./describe.js";
7
+ export { terminalServer } from "./terminal.js";
@@ -0,0 +1,70 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { Server } from "@modelcontextprotocol/server";
3
+ import { type EventSink } from "../../src/events.ts";
4
+ import { type AgentFiles } from "../../src/skills.ts";
5
+ import { type TracesOptions } from "../../src/traces.ts";
6
+ import { type CredentialProvider, type Tool } from "../../src/tools.ts";
7
+ import { type McpToolDescriptor, type Scopes } from "./describe.ts";
8
+ export interface McpAuth {
9
+ /** The authorization server(s) that issue tokens for this server, as hosts discover them. */
10
+ issuer: string | string[];
11
+ /** This server's public URL as hosts reach it, the resource tokens are for. Default: the request's own. */
12
+ resource?: string;
13
+ /** Scope names: reads need `read`, writes `write`. Without them, being signed in is enough. */
14
+ scopes?: Scopes;
15
+ /**
16
+ * Check a bearer token, on every request. Return who it is for (`key`: the business's own id for
17
+ * the person, as ToolContext.key) and the scopes it grants, or null when it is not valid.
18
+ */
19
+ verify(token: string, request: Request): Promise<McpIdentity | null> | McpIdentity | null;
20
+ }
21
+ export interface McpIdentity {
22
+ key: string;
23
+ scopes?: string[];
24
+ /** Request-bound backend access, such as a brand token exchange. Never exposed to the model. */
25
+ credentials?: CredentialProvider;
26
+ }
27
+ export interface McpServerOptions {
28
+ name: string;
29
+ version: string;
30
+ tools: Tool[];
31
+ /** The agent's folder, for its skills (AGENTS.md is the chat agent's and is not sent). Or `files`. */
32
+ dir?: string;
33
+ files?: AgentFiles;
34
+ /** Replaces the instructions a host gets on connecting (default: the skills and how to read them). */
35
+ instructions?: string;
36
+ auth?: McpAuth;
37
+ /** Backend access by verified account, separate from the bearer token issued for this MCP resource. */
38
+ credentials?: CredentialProvider;
39
+ /** Where the endpoint is mounted (default "/mcp"). */
40
+ path?: string;
41
+ /** Native plugins deliver skills themselves. Default: tools only at `${path}/plugin`. */
42
+ plugin?: {
43
+ path?: string;
44
+ } | false;
45
+ /** Host names allowed in the Host header. Default: checked only for localhost, against DNS rebinding. */
46
+ allowedHosts?: string[];
47
+ /** Change the descriptor of any tool (default: exactly what `describe` makes). */
48
+ descriptor?: (tool: Tool, generated: McpToolDescriptor) => McpToolDescriptor;
49
+ /** Add your own handlers (resources, prompts, ...) to each server the SDK makes; one level down. */
50
+ extend?: (server: Server) => void;
51
+ events?: EventSink[];
52
+ /** Traces from FLOW_FRAMEWORK_TRACES_* (or legacy CHINWAG_TRACES_*) by default; false disables them. */
53
+ traces?: TracesOptions | false;
54
+ }
55
+ export interface McpServerHandle {
56
+ /** Serve one web-standard request. */
57
+ fetch(request: Request): Promise<Response>;
58
+ /** Serve one node:http request. */
59
+ node(req: IncomingMessage, res: ServerResponse): Promise<void>;
60
+ /** The descriptors `tools/list` sends. */
61
+ descriptors: McpToolDescriptor[];
62
+ /** The tools-only plugin endpoint, unless disabled. */
63
+ plugin?: {
64
+ path: string;
65
+ descriptors: McpToolDescriptor[];
66
+ };
67
+ /** Wait for calls queued for tracing, for shutdown or short-lived servers. */
68
+ flushTraces(): Promise<void>;
69
+ }
70
+ export declare function mcpServer(options: McpServerOptions): McpServerHandle;