@flow-engineer/flow 0.2.0 → 0.2.2

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 (82) hide show
  1. package/LICENSE +202 -21
  2. package/README.md +190 -17
  3. package/coding-agents/auth-skill.md +137 -0
  4. package/coding-agents/context-skill.md +94 -0
  5. package/coding-agents/skill.md +245 -11
  6. package/coding-agents/test-skill.md +13 -11
  7. package/coding-agents/tools-skill.md +78 -1
  8. package/dist/cli/src/auth.d.ts +11 -0
  9. package/dist/cli/src/auth.js +147 -0
  10. package/dist/cli/src/commands.d.ts +119 -0
  11. package/dist/cli/src/commands.js +212 -0
  12. package/dist/cli/src/index.d.ts +2 -0
  13. package/dist/cli/src/index.js +5 -0
  14. package/dist/cli/src/run.d.ts +28 -0
  15. package/dist/cli/src/run.js +523 -0
  16. package/dist/cli/src/terminal.d.ts +3 -0
  17. package/dist/cli/src/terminal.js +135 -0
  18. package/dist/flow-messaging/src/client.d.ts +7 -1
  19. package/dist/flow-messaging/src/client.js +13 -1
  20. package/dist/flow-messaging/src/index.d.ts +38 -3
  21. package/dist/flow-messaging/src/index.js +242 -40
  22. package/dist/mcp/src/client.d.ts +54 -0
  23. package/dist/mcp/src/client.js +175 -0
  24. package/dist/mcp/src/describe.d.ts +41 -0
  25. package/dist/mcp/src/describe.js +110 -0
  26. package/dist/mcp/src/index.d.ts +4 -0
  27. package/dist/mcp/src/index.js +7 -0
  28. package/dist/mcp/src/server.d.ts +70 -0
  29. package/dist/mcp/src/server.js +252 -0
  30. package/dist/mcp/src/terminal.d.ts +24 -0
  31. package/dist/mcp/src/terminal.js +54 -0
  32. package/dist/simulators/src/index.d.ts +1 -1
  33. package/dist/simulators/src/page.html +267 -49
  34. package/dist/simulators/src/server.d.ts +27 -0
  35. package/dist/simulators/src/server.js +264 -40
  36. package/dist/src/agent.d.ts +69 -3
  37. package/dist/src/agent.js +162 -8
  38. package/dist/src/channels.d.ts +2 -0
  39. package/dist/src/conversation.d.ts +16 -1
  40. package/dist/src/conversation.js +140 -12
  41. package/dist/src/events.d.ts +1 -1
  42. package/dist/src/index.d.ts +2 -2
  43. package/dist/src/index.js +1 -1
  44. package/dist/src/model.d.ts +27 -0
  45. package/dist/src/model.js +85 -13
  46. package/dist/src/names.d.ts +5 -0
  47. package/dist/src/names.js +5 -0
  48. package/dist/src/skills.d.ts +4 -0
  49. package/dist/src/skills.js +23 -0
  50. package/dist/src/store.d.ts +2 -0
  51. package/dist/src/store.js +18 -0
  52. package/dist/src/tools.d.ts +69 -0
  53. package/dist/src/tools.js +17 -1
  54. package/dist/src/traces.js +32 -4
  55. package/dist/src/types.d.ts +2 -0
  56. package/dist/testing/src/account.d.ts +4 -0
  57. package/dist/testing/src/account.js +891 -0
  58. package/dist/testing/src/chat.d.ts +3 -0
  59. package/dist/testing/src/chat.js +125 -0
  60. package/dist/testing/src/check.d.ts +4 -0
  61. package/dist/testing/src/check.js +90 -0
  62. package/dist/testing/src/chinwag.js +1 -1
  63. package/dist/testing/src/cli.js +105 -18
  64. package/dist/testing/src/client.d.ts +2 -0
  65. package/dist/testing/src/client.js +4 -3
  66. package/dist/testing/src/flow-login.d.ts +176 -0
  67. package/dist/testing/src/flow-login.js +274 -0
  68. package/dist/testing/src/new.d.ts +15 -2
  69. package/dist/testing/src/new.js +80 -21
  70. package/dist/testing/src/plugin.d.ts +4 -0
  71. package/dist/testing/src/plugin.js +116 -0
  72. package/docs/agents.md +160 -0
  73. package/examples/cafe/src/agent.ts +6 -2
  74. package/examples/cafe/src/channels.ts +20 -0
  75. package/examples/cafe/src/cli.ts +24 -0
  76. package/examples/cafe/src/main.ts +17 -3
  77. package/examples/cafe/src/mcp.ts +22 -0
  78. package/examples/cafe/src/tools.ts +13 -0
  79. package/flow-messaging/README.md +66 -27
  80. package/package.json +30 -2
  81. package/spec/agent-endpoint.openapi.yaml +2 -2
  82. package/spec/nudges.openapi.yaml +2 -2
@@ -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;
@@ -0,0 +1,252 @@
1
+ // An agent's tools and skills as an MCP server (Streamable HTTP, spec 2026-07-28 with the 2025
2
+ // fallback), on the official SDK. It needs no model, channels or store: mount `fetch` on any
3
+ // web-standard runtime (Cloudflare Workers, Deno, Bun) or `node` on node:http.
4
+ //
5
+ // Sign-in is the host's OAuth against the business's own authorization server; this server only
6
+ // verifies the token on every request (`auth.verify`) and turns it into the person. A call that
7
+ // needs sign-in and has none is answered HTTP 401 with WWW-Authenticate before the SDK runs: hosts
8
+ // start sign-in only on that, never on a tool result. A token without a needed scope gets 403
9
+ // insufficient_scope. Without `auth`, every tool answers and the person is anonymous.
10
+ import { randomUUID } from "node:crypto";
11
+ import { createMcpHandler, hostHeaderValidationResponse, localhostAllowedHostnames, localhostAllowedOrigins, originValidationResponse, ProtocolError, ProtocolErrorCode, Server, } from "@modelcontextprotocol/server";
12
+ import { emitter } from "../../src/events.js";
13
+ import { loadSkills, mcpInstructions, readAgentFolder, readSkillTool } from "../../src/skills.js";
14
+ import { traces, tracesFromEnv } from "../../src/traces.js";
15
+ import { render, toolContext } from "../../src/tools.js";
16
+ import { describe, scopesFor, signInOf, toolsProblems } from "./describe.js";
17
+ const LOCAL = new Set(localhostAllowedHostnames());
18
+ export function mcpServer(options) {
19
+ if (options.dir !== undefined && options.files !== undefined)
20
+ throw new Error("@flow-engineer/flow/mcp: give dir or files, not both");
21
+ const skills = options.dir !== undefined || options.files !== undefined ? loadSkills(options.files ?? readAgentFolder(options.dir)) : [];
22
+ const tools = [...options.tools, ...(skills.length ? [readSkillTool(skills)] : [])];
23
+ const problems = toolsProblems(tools);
24
+ if (problems.length)
25
+ throw new Error(`@flow-engineer/flow/mcp: these tools cannot be served as declared:\n- ${problems.join("\n- ")}`);
26
+ const scopes = options.auth?.scopes;
27
+ const byName = new Map(tools.map((t) => [t.name, t]));
28
+ const descriptors = tools.map((t) => (options.descriptor ? options.descriptor(t, describe(t, scopes)) : describe(t, scopes)));
29
+ const path = options.path ?? "/mcp";
30
+ const pluginPath = options.plugin === false ? undefined : options.plugin?.path ?? `${path}/plugin`;
31
+ for (const endpoint of [path, pluginPath].filter((p) => p !== undefined)) {
32
+ if (!endpoint.startsWith("/") || /[?#]/.test(endpoint))
33
+ throw new Error("@flow-engineer/flow/mcp: paths must be absolute URL paths");
34
+ }
35
+ if (pluginPath === path)
36
+ throw new Error("@flow-engineer/flow/mcp: plugin and standalone paths must differ");
37
+ const pluginDescriptors = descriptors.slice(0, options.tools.length);
38
+ const traceOptions = options.traces === false ? undefined : options.traces ?? tracesFromEnv();
39
+ const trace = traceOptions ? traces(traceOptions, { service: options.name, kindOf: () => undefined }) : undefined;
40
+ const emit = emitter([...(options.events ?? []), ...(trace ? [trace.sink] : [])]);
41
+ // Legacy HTTP carries clientInfo only on initialize. Keep bounded metadata, never auth.
42
+ const sessions = new Map();
43
+ const SESSION_TTL = 24 * 60 * 60 * 1000;
44
+ function endpointHandler(plugin) {
45
+ const instructions = options.instructions ?? (plugin ? "" : mcpInstructions(skills));
46
+ const listed = plugin ? pluginDescriptors : descriptors;
47
+ const available = plugin ? new Map(options.tools.map((t) => [t.name, t])) : byName;
48
+ return createMcpHandler(({ requestInfo }) => {
49
+ const server = new Server({ name: options.name, version: options.version }, { capabilities: { tools: {} }, ...(instructions ? { instructions } : {}) });
50
+ server.setRequestHandler("tools/list", async () => ({ tools: listed }));
51
+ server.setRequestHandler("tools/call", async (request, ctx) => {
52
+ const t = available.get(request.params.name);
53
+ if (!t)
54
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No tool named ${request.params.name}.`);
55
+ const parsed = t.input.safeParse(request.params.arguments ?? {});
56
+ if (!parsed.success)
57
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for ${t.name}: ${parsed.error.message}`);
58
+ const identity = identityOf(ctx.http?.authInfo);
59
+ const person = identity ? `key:${identity.key}` : "mcp:anonymous";
60
+ const meta = ctx.mcpReq._meta ?? {};
61
+ const sentKey = meta["chinwag/idempotency-key"];
62
+ const session = requestInfo?.headers.get("mcp-session-id") ?? undefined;
63
+ const host = server.getClientVersion() ?? (session ? sessions.get(session)?.client : undefined);
64
+ const detail = {
65
+ surface: meta["chinwag/surface"] === "cli" ? "cli" : plugin ? "mcp-plugin" : "mcp",
66
+ client: host?.name,
67
+ clientVersion: host?.version,
68
+ session: ctx.sessionId ?? session,
69
+ person,
70
+ command: typeof meta["chinwag/command"] === "string" ? meta["chinwag/command"] : undefined,
71
+ };
72
+ const context = toolContext({
73
+ conversationId: `mcp:${person}`,
74
+ person,
75
+ key: identity?.key ?? null,
76
+ channel: "mcp",
77
+ callId: randomUUID(),
78
+ // CLI retries can preserve a call key; other hosts get one per request.
79
+ idempotencyKey: typeof sentKey === "string" && sentKey.length > 0 && sentKey.length <= 256 ? sentKey : randomUUID(),
80
+ signal: ctx.mcpReq?.signal ?? new AbortController().signal,
81
+ }, ctx.http?.authInfo?.extra?.credentials ?? options.credentials);
82
+ const started = Date.now();
83
+ emit(context.conversationId, "tool.start", { callId: context.callId, tool: t.name, input: parsed.data, ...detail });
84
+ const result = await callTool(t, parsed.data, context);
85
+ emit(context.conversationId, "tool.end", { callId: context.callId, tool: t.name, ms: Date.now() - started, ok: !result.isError, output: result.structuredContent ?? result.content, error: result.isError ? result.content.filter(c => c.type === "text").map(c => c.text).join("\n") : undefined, ...detail });
86
+ return result;
87
+ });
88
+ options.extend?.(server);
89
+ return server;
90
+ });
91
+ }
92
+ const handler = endpointHandler(false);
93
+ const pluginHandler = pluginPath ? endpointHandler(true) : undefined;
94
+ async function serve(request) {
95
+ const url = new URL(request.url);
96
+ const metadataPrefix = "/.well-known/oauth-protected-resource";
97
+ const plugin = pluginPath !== undefined && (url.pathname === pluginPath || url.pathname === `${metadataPrefix}${pluginPath}`);
98
+ const servedPath = plugin ? pluginPath : path;
99
+ if (request.method === "GET" && (url.pathname === metadataPrefix || url.pathname === `${metadataPrefix}${servedPath}`)) {
100
+ if (!options.auth)
101
+ return new Response("Not found", { status: 404 });
102
+ return Response.json({
103
+ resource: resourceOf(url, servedPath),
104
+ authorization_servers: [options.auth.issuer].flat(),
105
+ bearer_methods_supported: ["header"],
106
+ ...(scopes && (scopes.read || scopes.write) ? { scopes_supported: [...new Set([scopes.read, scopes.write].filter(Boolean))] } : {}),
107
+ });
108
+ }
109
+ if (url.pathname !== servedPath)
110
+ return new Response("Not found", { status: 404 });
111
+ const rejected = options.allowedHosts
112
+ ? hostHeaderValidationResponse(request, options.allowedHosts)
113
+ : LOCAL.has(url.hostname)
114
+ ? (hostHeaderValidationResponse(request, localhostAllowedHostnames()) ?? originValidationResponse(request, localhostAllowedOrigins()))
115
+ : undefined;
116
+ if (rejected)
117
+ return rejected;
118
+ for (const [id, saved] of sessions)
119
+ if (Date.now() - saved.at > SESSION_TTL)
120
+ sessions.delete(id);
121
+ const session = request.headers.get("mcp-session-id");
122
+ if (session && sessions.get(session)?.path !== servedPath)
123
+ return new Response("Session not found", { status: 404 });
124
+ let authInfo;
125
+ const bearer = /^Bearer\s+(.+)$/i.exec(request.headers.get("authorization") ?? "")?.[1];
126
+ if (options.auth && bearer) {
127
+ const identity = await options.auth.verify(bearer, request);
128
+ if (!identity)
129
+ return challenge(url, servedPath, 401, "invalid_token", "The token is not valid.", []);
130
+ authInfo = { token: bearer, clientId: "", scopes: identity.scopes ?? [], extra: { key: identity.key } };
131
+ if (identity.credentials)
132
+ Object.defineProperty(authInfo.extra, "credentials", { value: identity.credentials, enumerable: false });
133
+ }
134
+ let parsedBody;
135
+ if (request.method === "POST") {
136
+ try {
137
+ parsedBody = JSON.parse(await request.clone().text());
138
+ }
139
+ catch {
140
+ parsedBody = undefined;
141
+ }
142
+ if (options.auth) {
143
+ for (const message of [parsedBody].flat()) {
144
+ const call = message;
145
+ if (!call || call.method !== "tools/call" || typeof call.params?.name !== "string")
146
+ continue;
147
+ const t = plugin && call.params.name === "read_skill" ? undefined : byName.get(call.params.name);
148
+ if (!t || signInOf(t) === "none")
149
+ continue;
150
+ const needed = scopesFor(t, scopes);
151
+ if (!authInfo) {
152
+ if (signInOf(t) === "required")
153
+ return challenge(url, servedPath, 401, "invalid_token", `Sign in to use ${t.name}.`, needed);
154
+ continue;
155
+ }
156
+ const missing = needed.filter((s) => !authInfo.scopes.includes(s));
157
+ if (missing.length)
158
+ return challenge(url, servedPath, 403, "insufficient_scope", `${t.name} needs ${missing.join(" ")}.`, [...new Set([...authInfo.scopes, ...needed])]);
159
+ }
160
+ }
161
+ }
162
+ if (request.method === "DELETE" && session) {
163
+ sessions.delete(session);
164
+ return new Response(null, { status: 200 });
165
+ }
166
+ const response = await (plugin ? pluginHandler : handler).fetch(request, { authInfo, parsedBody });
167
+ const init = parsedBody;
168
+ if (response.ok && init?.method === "initialize") {
169
+ const client = init.params?.clientInfo;
170
+ if (typeof client?.name === "string" && typeof client.version === "string") {
171
+ const id = randomUUID();
172
+ sessions.set(id, { client: { name: client.name.slice(0, 256), version: client.version.slice(0, 256) }, path: servedPath, at: Date.now() });
173
+ if (sessions.size > 4096)
174
+ sessions.delete(sessions.keys().next().value);
175
+ const headers = new Headers(response.headers);
176
+ headers.set("mcp-session-id", id);
177
+ return new Response(response.body, { status: response.status, headers });
178
+ }
179
+ }
180
+ return response;
181
+ }
182
+ function resourceOf(url, servedPath) {
183
+ const resource = options.auth?.resource ?? `${url.origin}${path}`;
184
+ if (servedPath === path)
185
+ return resource;
186
+ const pluginResource = new URL(resource);
187
+ pluginResource.pathname = servedPath;
188
+ return pluginResource.href;
189
+ }
190
+ function challenge(url, servedPath, status, error, description, scope) {
191
+ const metadata = `${new URL(resourceOf(url, servedPath)).origin}/.well-known/oauth-protected-resource${servedPath}`;
192
+ const header = `Bearer error="${error}", error_description="${description}", resource_metadata="${metadata}"${scope.length ? `, scope="${scope.join(" ")}"` : ""}`;
193
+ return Response.json({ error, error_description: description }, { status, headers: { "WWW-Authenticate": header } });
194
+ }
195
+ return {
196
+ fetch: serve,
197
+ node: (req, res) => serveNode(serve, req, res),
198
+ descriptors,
199
+ flushTraces: () => trace?.flush() ?? Promise.resolve(),
200
+ ...(pluginPath ? { plugin: { path: pluginPath, descriptors: pluginDescriptors } } : {}),
201
+ };
202
+ }
203
+ function identityOf(authInfo) {
204
+ const key = authInfo?.extra?.key;
205
+ return typeof key === "string" ? { key, scopes: authInfo.scopes } : null;
206
+ }
207
+ async function callTool(t, input, context) {
208
+ let output;
209
+ try {
210
+ output = await t.run(input, context);
211
+ }
212
+ catch (error) {
213
+ return { isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] };
214
+ }
215
+ if (!t.output)
216
+ return { content: [{ type: "text", text: render(output) }] };
217
+ const checked = t.output.safeParse(output);
218
+ if (!checked.success)
219
+ return { isError: true, content: [{ type: "text", text: `${t.name} returned a result that does not match its output schema.` }] };
220
+ return { content: [{ type: "text", text: render(checked.data) }], structuredContent: checked.data };
221
+ }
222
+ /** node:http in front of the web-standard handler, streaming the answer (event streams included). */
223
+ async function serveNode(serve, req, res) {
224
+ const abort = new AbortController();
225
+ res.on("close", () => abort.abort());
226
+ const secure = req.socket.encrypted === true;
227
+ const url = `${secure ? "https" : "http"}://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
228
+ const headers = new Headers();
229
+ for (const [name, value] of Object.entries(req.headers)) {
230
+ if (value !== undefined)
231
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
232
+ }
233
+ let body;
234
+ if (req.method !== "GET" && req.method !== "HEAD") {
235
+ const chunks = [];
236
+ for await (const chunk of req)
237
+ chunks.push(chunk);
238
+ const all = Buffer.concat(chunks);
239
+ body = all.buffer.slice(all.byteOffset, all.byteOffset + all.byteLength);
240
+ }
241
+ const response = await serve(new Request(url, { method: req.method, headers, body, signal: abort.signal }));
242
+ res.writeHead(response.status, Object.fromEntries(response.headers));
243
+ if (!response.body)
244
+ return void res.end();
245
+ try {
246
+ for await (const chunk of response.body)
247
+ res.write(chunk);
248
+ }
249
+ finally {
250
+ res.end();
251
+ }
252
+ }