Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 140 additions & 43 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ const integer = (description: string): Record<string, unknown> => ({
description,
});

/**
* The tools that answer with no token at all, for the OpenMCP descriptor's
* `auth.open`. Everything else calls signInFirst on a 401. Kept beside the
* list rather than derived from it, so a new tool has to say which it is.
*/
export const OPEN_TOOLS = [
'search_jobs',
'get_job',
'get_apply_schema',
'apply_to_job',
'list_employers',
'whoami',
'search_network',
'list_instances',
] as const;

export const TOOLS: ToolDefinition[] = [
{
name: 'search_jobs',
Expand Down Expand Up @@ -150,19 +166,30 @@ export const TOOLS: ToolDefinition[] = [
org: string('The employer slug to post under.'),
title: string('The role.'),
description: string('The listing body, as Markdown.'),
employmentType: { type: 'string', enum: ['full-time', 'part-time', 'contract', 'internship', 'temporary'] },
employmentType: {
type: 'string',
enum: ['full-time', 'part-time', 'contract', 'internship', 'temporary'],
},
workplace: { type: 'string', enum: ['remote', 'hybrid', 'onsite'] },
seniority: { type: 'string', enum: ['intern', 'junior', 'mid', 'senior', 'staff', 'principal', 'lead'] },
seniority: {
type: 'string',
enum: ['intern', 'junior', 'mid', 'senior', 'staff', 'principal', 'lead'],
},
location: string('Free text.'),
pay: {
type: 'array',
items: { type: 'string' },
description:
'What it pays, one line per price, the way a person says it: "$120k - $150k a year", "$100 an hour", "$0.25 per task", "$0.25 per PR that fixes a bug you find", "$5000 fixed", "10% revenue share", "0.01 SOL per task". Required before the listing can be published.',
},
payMethod: string('How it is settled: a coin such as SOL, USDC, ETH, USDT or POL, or a rail such as "bank transfer", "PayPal" or "payroll".'),
payMethod: string(
'How it is settled: a coin such as SOL, USDC, ETH, USDT or POL, or a rail such as "bank transfer", "PayPal" or "payroll".',
),
payEquity: string('Equity, as text. Optional.'),
unpaid: { type: 'boolean', description: 'True only when the role pays nothing and the employer says so.' },
unpaid: {
type: 'boolean',
description: 'True only when the role pays nothing and the employer says so.',
},
salaryMin: integer('Bottom of an annual range. Older form of pay; prefer `pay`.'),
salaryMax: integer('Top of an annual range. Older form of pay; prefer `pay`.'),
salaryCurrency: string('ISO code, e.g. USD.'),
Expand Down Expand Up @@ -241,7 +268,7 @@ export const TOOLS: ToolDefinition[] = [
name: 'follow',
title: 'Follow or unfollow',
description:
'Follow an employer or a candidate so their updates appear in this account\'s feed. Set following to false to stop. Following twice is following once.',
"Follow an employer or a candidate so their updates appear in this account's feed. Set following to false to stop. Following twice is following once.",
inputSchema: object(
{
org: string("An employer's slug."),
Expand Down Expand Up @@ -423,7 +450,10 @@ export async function callTool(
}

case 'get_job': {
const response = await caller.call('GET', `/api/v1/jobs/${encodeURIComponent(String(args['slug'] ?? ''))}`);
const response = await caller.call(
'GET',
`/api/v1/jobs/${encodeURIComponent(String(args['slug'] ?? ''))}`,
);
if (response.status !== 200) return toolError(message(response.body, 'No such job.'));
return text(describeJob(response.body), response.body);
}
Expand Down Expand Up @@ -454,8 +484,9 @@ export async function callTool(
body,
);
if (response.status !== 201) {
const fields = (response.body as { error?: { fields?: { field: string; message: string }[] } })
.error?.fields;
const fields = (
response.body as { error?: { fields?: { field: string; message: string }[] } }
).error?.fields;
const detail =
fields === undefined
? ''
Expand Down Expand Up @@ -499,23 +530,32 @@ export async function callTool(
? await caller.call('PATCH', `/api/v1/resumes/${encodeURIComponent(slug)}`, body)
: await caller.call('POST', '/api/v1/resumes', body);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status >= 400) return toolError(message(response.body, 'The resume was not saved.'));
if (response.status >= 400)
return toolError(message(response.body, 'The resume was not saved.'));
return text('Saved.', response.body);
}

case 'post_job': {
const response = await caller.call('POST', '/api/v1/jobs', args);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 201) return toolError(message(response.body, 'The job was not created.'));
const created = (response.body as {
job?: { slug?: string; pay?: { unpaid?: boolean; lines?: { min?: number | null; max?: number | null }[] } };
}).job;
if (response.status !== 201)
return toolError(message(response.body, 'The job was not created.'));
const created = (
response.body as {
job?: {
slug?: string;
pay?: { unpaid?: boolean; lines?: { min?: number | null; max?: number | null }[] };
};
}
).job;
const stated =
created?.pay?.unpaid === true ||
(created?.pay?.lines ?? []).some((line) => line.min != null || line.max != null);
return text(
`Created as a draft: ${created?.slug ?? 'unknown'}. It is not visible to anyone until publish_job is called.${
stated ? '' : ' It does not say what it pays yet, and cannot be published until it does: send pay, one line per price, or unpaid: true.'
stated
? ''
: ' It does not say what it pays yet, and cannot be published until it does: send pay, one line per price, or unpaid: true.'
}`,
response.body,
);
Expand All @@ -527,7 +567,8 @@ export async function callTool(
`/api/v1/jobs/${encodeURIComponent(String(args['slug'] ?? ''))}/publish`,
);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status >= 400) return toolError(message(response.body, 'The job was not published.'));
if (response.status >= 400)
return toolError(message(response.body, 'The job was not published.'));
return text('Published.', response.body);
}

Expand All @@ -537,7 +578,8 @@ export async function callTool(
`/api/v1/jobs/${encodeURIComponent(String(args['slug'] ?? ''))}/applications`,
);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status >= 400) return toolError(message(response.body, 'Could not read applications.'));
if (response.status >= 400)
return toolError(message(response.body, 'Could not read applications.'));
return text(JSON.stringify(response.body, null, 2), response.body);
}

Expand Down Expand Up @@ -608,7 +650,8 @@ export async function callTool(
case 'recommend': {
const candidate = typeof args['candidate'] === 'string' ? args['candidate'] : '';
const org = typeof args['org'] === 'string' ? args['org'] : '';
if (candidate === '' && org === '') return toolError('Name a candidate with candidate, or an employer with org.');
if (candidate === '' && org === '')
return toolError('Name a candidate with candidate, or an employer with org.');
const path =
candidate !== ''
? `/api/v1/candidates/${encodeURIComponent(candidate)}/recommendations`
Expand All @@ -619,8 +662,13 @@ export async function callTool(
...(typeof args['as'] === 'string' && args['as'] !== '' ? { as: args['as'] } : {}),
});
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 201) return toolError(message(response.body, 'The recommendation was not written.'));
const written = (response.body as { recommendation?: { subject?: { name?: string }; author?: { name?: string } } }).recommendation;
if (response.status !== 201)
return toolError(message(response.body, 'The recommendation was not written.'));
const written = (
response.body as {
recommendation?: { subject?: { name?: string }; author?: { name?: string } };
}
).recommendation;
return text(
`Written, from ${written?.author?.name ?? 'you'}. It is pending until ${written?.subject?.name ?? 'they'} approves it, and is not on their page until then.`,
response.body,
Expand All @@ -631,31 +679,48 @@ export async function callTool(
const response = await caller.call('GET', '/api/v1/me/recommendations');
if (response.status === 401) return toolError(signInFirst(caller));
const mine = response.body as {
received: { id: string; status: string; author: { name: string }; subject: { name: string }; body: string }[];
received: {
id: string;
status: string;
author: { name: string };
subject: { name: string };
body: string;
}[];
given: { id: string; status: string; subject: { name: string }; body: string }[];
pending: number;
};
const lines = [
`${mine.pending} waiting for a decision.`,
'',
...mine.received.map((item) => `- [${item.status}] ${item.author.name} about ${item.subject.name}: ${item.body.slice(0, 100)} [${item.id}]`),
...mine.received.map(
(item) =>
`- [${item.status}] ${item.author.name} about ${item.subject.name}: ${item.body.slice(0, 100)} [${item.id}]`,
),
...(mine.given.length > 0 ? ['', 'You wrote:'] : []),
...mine.given.map((item) => `- [${item.status}] about ${item.subject.name}: ${item.body.slice(0, 100)} [${item.id}]`),
...mine.given.map(
(item) =>
`- [${item.status}] about ${item.subject.name}: ${item.body.slice(0, 100)} [${item.id}]`,
),
];
return text(lines.join('\n'), response.body);
}

case 'decide_recommendation': {
const action = String(args['action'] ?? '');
if (!['approve', 'reject', 'withdraw'].includes(action)) return toolError('action is approve, reject or withdraw.');
if (!['approve', 'reject', 'withdraw'].includes(action))
return toolError('action is approve, reject or withdraw.');
const response = await caller.call(
'POST',
`/api/v1/recommendations/${encodeURIComponent(String(args['id'] ?? ''))}/${action}`,
);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 200) return toolError(message(response.body, 'Nothing changed.'));
return text(
action === 'approve' ? 'Approved. It is on the page now.' : action === 'reject' ? 'Rejected. It is not shown.' : 'Withdrawn.',
action === 'approve'
? 'Approved. It is on the page now.'
: action === 'reject'
? 'Rejected. It is not shown.'
: 'Withdrawn.',
response.body,
);
}
Expand All @@ -666,7 +731,9 @@ export async function callTool(
`/api/v1/directory/search${query(args, ['q', 'workplace', 'agentPolicy', 'limit'])}`,
);
if (response.status === 404) {
return toolError(`${caller.server} is a board, not a directory, so it has no network to search.`);
return toolError(
`${caller.server} is a board, not a directory, so it has no network to search.`,
);
}
const result = response.body as {
jobs?: { job: { title: string }; instanceName: string; url: string }[];
Expand All @@ -681,7 +748,9 @@ export async function callTool(
? ''
: `\n\n${failed.length} board(s) did not answer, so their listings are missing: ${failed.map((source) => source.name).join(', ')}.`;
return text(
lines.length === 0 ? `Nothing matched on any listed board.${note}` : `${lines.join('\n')}${note}`,
lines.length === 0
? `Nothing matched on any listed board.${note}`
: `${lines.join('\n')}${note}`,
response.body,
);
}
Expand All @@ -700,7 +769,8 @@ export async function callTool(
const path = thread === '' ? '/api/v1/inbox' : `/api/v1/inbox/${encodeURIComponent(thread)}`;
const response = await caller.call('GET', path);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 200) return toolError(message(response.body, 'No such conversation.'));
if (response.status !== 200)
return toolError(message(response.body, 'No such conversation.'));
if (thread === '') {
const page = response.body as { items?: unknown[] };
if ((page.items ?? []).length === 0) return text('The inbox is empty.', response.body);
Expand All @@ -712,9 +782,13 @@ export async function callTool(
const body = String(args['body'] ?? '');
const thread = typeof args['thread'] === 'string' ? args['thread'].trim() : '';
if (thread !== '') {
const response = await caller.call('POST', `/api/v1/inbox/${encodeURIComponent(thread)}/messages`, {
body,
});
const response = await caller.call(
'POST',
`/api/v1/inbox/${encodeURIComponent(thread)}/messages`,
{
body,
},
);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 201) return toolError(message(response.body, 'Not sent.'));
return text('Sent.', response.body);
Expand All @@ -740,13 +814,20 @@ export async function callTool(

case 'send_invoice': {
const thread = String(args['thread'] ?? '').trim();
const response = await caller.call('POST', `/api/v1/inbox/${encodeURIComponent(thread)}/invoices`, {
amount: String(args['amount'] ?? ''),
...(typeof args['currency'] === 'string' && args['currency'] !== '' ? { currency: args['currency'] } : {}),
...(typeof args['description'] === 'string' ? { description: args['description'] } : {}),
});
const response = await caller.call(
'POST',
`/api/v1/inbox/${encodeURIComponent(thread)}/invoices`,
{
amount: String(args['amount'] ?? ''),
...(typeof args['currency'] === 'string' && args['currency'] !== ''
? { currency: args['currency'] }
: {}),
...(typeof args['description'] === 'string' ? { description: args['description'] } : {}),
},
);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 201) return toolError(message(response.body, 'The invoice was not sent.'));
if (response.status !== 201)
return toolError(message(response.body, 'The invoice was not sent.'));
const sent = response.body as { invoice?: { amountUsd?: string; currency?: string } };
return text(
`Invoice sent for $${sent.invoice?.amountUsd ?? ''} in ${sent.invoice?.currency ?? ''}. The other side sees Pay in the conversation.`,
Expand All @@ -759,14 +840,22 @@ export async function callTool(
if (invoice === '') {
const response = await caller.call('GET', '/api/v1/invoices');
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 200) return toolError(message(response.body, 'Could not list invoices.'));
if (response.status !== 200)
return toolError(message(response.body, 'Could not list invoices.'));
return text(JSON.stringify(response.body, null, 2), response.body);
}
const response = await caller.call('POST', `/api/v1/invoices/${encodeURIComponent(invoice)}/pay`);
const response = await caller.call(
'POST',
`/api/v1/invoices/${encodeURIComponent(invoice)}/pay`,
);
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 200) return toolError(message(response.body, 'No quote.'));
const result = response.body as {
invoice?: { status?: string; payment?: { url?: string; address?: string; amountCrypto?: string } | null; currency?: string };
invoice?: {
status?: string;
payment?: { url?: string; address?: string; amountCrypto?: string } | null;
currency?: string;
};
};
const inv = result.invoice;
if (inv?.status === 'paid') return text('That invoice is already paid.', response.body);
Expand All @@ -782,18 +871,26 @@ export async function callTool(
case 'check_billing': {
const response = await caller.call('GET', '/api/v1/coinpay');
if (response.status === 401) return toolError(signInFirst(caller));
if (response.status !== 200) return toolError(message(response.body, 'Could not read billing.'));
if (response.status !== 200)
return toolError(message(response.body, 'Could not read billing.'));
const state = response.body as {
configured?: boolean;
account?: { usable?: boolean; wallets?: { chain: string; address: string }[] } | null;
connectUrl?: string;
};
if (state.configured !== true) return text('This board has no billing configured.', response.body);
if (state.configured !== true)
return text('This board has no billing configured.', response.body);
if (state.account === null || state.account === undefined) {
return text(`No CoinPay account connected. Connect one in a browser: ${state.connectUrl ?? ''}`, response.body);
return text(
`No CoinPay account connected. Connect one in a browser: ${state.connectUrl ?? ''}`,
response.body,
);
}
if (state.account.usable !== true) {
return text(`The CoinPay connection has lapsed. Reconnect in a browser: ${state.connectUrl ?? ''}`, response.body);
return text(
`The CoinPay connection has lapsed. Reconnect in a browser: ${state.connectUrl ?? ''}`,
response.body,
);
}
const wallets = state.account.wallets ?? [];
return text(
Expand Down
22 changes: 22 additions & 0 deletions src/server/routes/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { Hono } from 'hono';
import { OPEN_TOOLS, TOOLS } from '../../mcp/tools.ts';
import type { Context } from 'hono';
import { readFile } from 'node:fs/promises';
import { dirname, extname, join, normalize } from 'node:path';
Expand Down Expand Up @@ -901,6 +902,27 @@ export function discoveryRoutes(): Hono<AppEnv> {
}
});

/**
* The board as an OpenMCP relay (logicsrc.com/openmcp): where its MCP
* endpoint is, how a caller authenticates, which tools are open, what it is
* for. A catalog that fetches this from our own origin lists the board as
* verified; one that only found /api/mcp lists it as online and nameless.
*/
routes.get('/.well-known/openmcp.json', (c) => {
const { config } = c.get('deps');
return c.json({
openmcp: '0.1',
mcp: `${config.publicUrl}/api/mcp`,
name: config.boardName,
description: `${config.boardName}: a job board where agents apply to agents, with a person at both ends. Listings, resumes, applications, updates and messages, over MCP.`,
url: config.publicUrl,
auth: { kind: 'bearer', url: `${config.publicUrl}/docs`, open: [...OPEN_TOOLS] },
tags: ['jobs', 'hiring', 'agents', 'openjob', 'openresume', 'openprofile'],
tools: TOOLS.map((tool) => tool.name),
catalogs: ['https://openmcp.logicsrc.com'],
});
});

routes.get('/.well-known/security.txt', (c) => {
const { config } = c.get('deps');
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
Expand Down
Loading
Loading